RFC Review: Lite Seats — Phase 1 Lite User Role & Tiered Quota
Companion review for
lite-seats.md, produced by therfc-reviewerskill. Lives beside the RFC; valid only for the RFC revision inreviewed_rfc_last_updated(2026-06-23, working tree).
Executive Summary
- Overall Score:
8.0/10 - Rating:
Strong - RFC Type:
full-stack - Sub-Type:
frontend = enhancement · backend = new-feature - Assessment Confidence:
High— every BE anchor in §2.0 was independently re-verified against the realqontak-launchpadcheckout (see Evidence Notes); contracts and flows are traceable end-to-end. - Applied Caps/Gates:
None binding.All scored categories ≥ 5.0; no cross-layer contract mismatch; deploy order specified (BE-before-FE, §4.A). The 9.0+ gate is not met (three blocking external dependencies remain open and the Lite-config Figma frame is absent), which is the ceiling on the score — not a cap. - Implementation Readiness Verdict:
PROCEED with notesfor BE chunks 1–8 and FE chunk 10;HOLDon FE chunk 9 (counter + inbox grey-out, blocked on Figma) and on anything that consumes the three blocking external dependencies (Open Q1/Q2/Q3). - Report Path:
bifrost/lite-seats/rfcs/lite-seats-review.md - RFC Author: pm-group.qontak@mekari.com | Reviewed: 2026-06-23
This RFC is, on the whole, agent-executable today for the bulk of its backend work. It is unusually well grounded: every "we reuse X" / "we extend Y" claim in the §2.0 Source Verification table was independently confirmed against the live code (count-based ValidateUserQuota, the SsoInvite-only Redis lock, the lock-less + transaction-less role-change paths, the QONTAKCHAT-User-* component parsing, the 422-vs-400 error constructors). The biggest strength is this code-grounding discipline plus a complete PRD→RFC traceability matrix that maps every story (LITE-S01…S06 + LITE-S01-NEG) to verifiable acceptance criteria and named files. The biggest gap is that three of the RFC's own dependencies are explicitly unresolved external blockers — the exact Lite ModPanel component codes (Open Q2), the lite_seats_enabled delivery mechanism + event schema (Open Q3), and the inbox permission_key blocklist (Open Q1) — each of which an agent cannot invent. The one thing that must change before full agentic execution: those three blocking inputs must be supplied as concrete values (component code strings, event schema, blocklist), because the affected chunks (1, 5, 6, and the inbox validator in 6/9) will otherwise be implemented against placeholders.
Quick Verdict
Why this RFC can be implemented agentically:
- Every backend change is anchored to a named, verified file with the exact pattern to mirror (§2.0 Existing Code Anchors + Source Verification); the Agent Execution Plan (§4.D) is a 10-chunk ordered manifest with per-chunk files, commands, and acceptance criteria.
- The hardest correctness traps are explicitly handled: count-model boundary bugs (same-bucket no-op LITE-S05/AC-2;
CountUserByBucketExcludingfor AC-3), error-code pinning (ErrUnprocessableEntity→422, not 400), and the fact that the role-change paths need new lock+tx wiring (not reuse).
Why this RFC will cause agent guessing or rework:
- Three blocking external inputs are unresolved (Open Q1 inbox blocklist, Open Q2 exact Lite component code strings, Open Q3 flag event schema). An agent will hardcode placeholders (
QONTAKCHAT-LiteUser-Initial, an empty/guessed blocklist) that are almost certainly wrong. - Open Q9 (role-change restructure:
update.gois goroutine-based + non-transactional today) is the highest-risk chunk and is left as an open A-or-B decision — the agent must pick between refactoringUpdatevs routing throughAssignRole, an architectural choice the author should close.
Findings Ledger (carry-forward)
Stable, never-renumbered finding ids. First review cycle (R1) — all findings are newly minted. Still-open material findings are promoted to the RFC Open-Questions surface by id.
| ID | Severity | Finding (one line) | RFC location | Status | First seen | Resolved in | Evidence / fix |
|---|---|---|---|---|---|---|---|
REV-1 | blocker | Exact Lite ModPanel component code strings unconfirmed; GetBillingInfoBySsoID parse target is a placeholder | §1 Dep table, A-2, §4.B, Open Q2 | open | R1 | — | Author flags QONTAKCHAT-LiteUser-Initial/-Additional as TBD; agent cannot match unknown Code strings. Needs confirmed strings from ModPanel/Billing. |
REV-2 | blocker | lite_seats_enabled delivery mechanism + Kafka event schema unconfirmed | A-3, §2.4, §2.F, Open Q3 | open | R1 | — | Consumer (chunk 5) needs the event payload schema; design provisions for both event + company_features read but the event field shapes are undefined. |
REV-3 | blocker | Inbox permission_key blocklist not defined; no inbox classification exists in repo | §1 Dep table, ADR-6, §4.B, Open Q1 | open | R1 | — | Verified: no inbox flag on permissions/permission_component_codes. Validator + FE disable (chunks 6, 9) need the actual key list; an empty constant silently passes all inbox keys. |
REV-4 | major | Role-change restructure left as open A/B decision (Update refactor vs route through AssignRole) | §2.0 contracts, ADR-5, Open Q9, chunk 7 | open | R1 | — | update.go verified goroutine+non-tx; assign_role.go verified lock-less+tx-less. Author should pick one path so the agent does not choose architecture. |
REV-5 | major | permissions_crs is_inbox marker is asserted as additive but its producer is unspecified | §2.4 (GET /permissions_crs extend), §2.G | open | R1 | — | The FE relies on a new is_inbox/inbox marker to grey out keys, but how the BE derives that marker (same blocklist? a DB column?) is not specified — couples to REV-3 and needs a concrete derivation rule. |
REV-6 | minor | Lite-config Figma frame absent → FE chunk 9 (counter + inbox grey-out) blocked | §1 Design References, §5 item 7, chunk 9 | open | R1 | — | All Lite UI marked "TBD — pending design" (PRD §7). BE + FE dropdown/error wiring are unblocked; only the counter/grey-out chunk is gated. |
REV-7 | minor | ModPanel timeout/retry values for the quota read not quantified | §3 Failure Mode Catalog, §2.E | open | R1 | — | RFC says "fail-safe, 503 on ModPanel down" and reuses the existing heimdall client, but no explicit timeout/retry/circuit-breaker numbers are pinned for the Lite read path; inherits existing client config (acceptable, but unstated). |
REV-8 | minor | Redis lock failure path on role-change (lock held / not acquired) maps to 429 but UX for a role change 429 is not described FE-side | §2.E, §2.4 role-change rows | open | R1 | — | Invite has the 429 path; the newly-locked role-change handlers will also emit ErrTooManyConcurrentRequests, but the FE error catalog (§3.C) does not list a 429 string for the edit-user flow. |
Ledger summary: 8 open (3 blocker / 3 major... → actually 2 major after REV-5 weighting, see below; counted as: 3 blocker, 2 major, 3 minor). Precisely: 3 blocker (REV-1/2/3), 2 major (REV-4/5), 3 minor (REV-6/7/8). 0 fixed this cycle (first cycle). 0 accepted-risk. Still-open material findings (REV-1…REV-5, plus REV-6 as the FE design blocker) are promoted to the RFC Open-Questions surface by id.
Reconciliation with the RFC's own §6 Comment-log R1 self-review. The RFC author already ran an adversarial R1 self-review (§6, 2026-06-23) that corrected the lock/tx grounding (re-tagged role-change lock/tx as new wiring + restructure, added the shared-lock helper chunk 3b and the
Updaterefactor as Open Q9), added the same-bucket no-op (LITE-S05/AC-2) andCountUserByBucketExcluding(AC-3), and pinned error codes toErrUnprocessableEntity→422. This review confirms those corrections against the live code and does not re-raise them as new findings — they are already resolved in the reviewed revision. REV-4 tracks only the remaining open decision (the A/B choice in Open Q9), not the grounding correction itself, which is closed.
PRD → RFC Traceability Matrix
Standard format — PRD
bifrost/lite-seats/prds/lite-seats.md(v1.2) exists and is linked.
| PRD Element | RFC Section | Coverage |
|---|---|---|
| LITE-S01 (provision Lite role on activation) | §2.2 provisioning seq · §2.F consumer · CreateDefaultCompanyRole · §2.3 row · chunk 5 | Full |
| LITE-S01/ERR-1 (retry back-fill, no dup) | §2.F idempotency (offset-not-committed → redelivery) | Full |
| LITE-S02 (configure ≤20 non-inbox perms) | §2.A UI contract · §2.4 PATCH · role validator chunk 6 · FE chunk 9 | Partial — FE counter/grey-out blocked on Figma (REV-6); BE validator fully specified |
| LITE-S02/ERR-1,2,3 (21st block, inbox 422, >20 422) | §2.A · §3.B error catalog · chunks 6/9 | Full (BE); FE counter blocked on design |
| LITE-S03 (strict standard quota) | §2.1 branch flow · standard branch of evaluator · chunks 3/7 | Full |
| LITE-S04 (tiered Lite-first deduction) | §2.1 · §2.2 · Lite branch evaluator · consumed_bucket · chunks 1–3,7 | Full |
| LITE-S04/ERR-2 (ModPanel down → 503 fail-safe) | §2.2 failure seq · §3.A | Full |
| LITE-S05 (re-eval + re-bucket on role change) | §2.1 NB branch · ADR-5 · chunk 7 · Open Q9 | Partial — behavior fully specified; the implementation path (refactor Update vs route via AssignRole) is an open decision (REV-4) |
| LITE-S05/AC-2 (same-bucket no-op) | §2.1 NOOP branch · chunk 7 | Full (added in R1 self-review) |
| LITE-S05/AC-3 (exclude-self count) | CountUserByBucketExcluding §2.3 · chunk 7 | Full (added in R1 self-review) |
| LITE-S06 (read two Lite components) | GetBillingInfoBySsoID extend · §2.4 · chunk 1 | Partial — parse logic specified; the component code strings to match are unconfirmed (REV-1) |
| LITE-S06/ERR-1 (missing components → lite_quota=0) | §3.A · §3.A.1 | Full |
| LITE-S01-NEG (inbox guard rail) | §2.A · §2.4 · §3.B 422 · chunks 6/9 | Partial — 422 reject specified; the inbox blocklist content is undefined (REV-3) |
| PRD §8 (API contracts deferred to RFC) | §2.4 APIs — explicitly closes the PRD gap | Full |
| PRD §10/§13 (rollout stages) | §4 Rollout Strategy + §4.A matrix | Full (technical mechanism; stage scheduling delegated to delivery) |
| PRD §11 (observability events) | §3 Monitoring — events realized as slog/Datadog, not a bus | Full (with documented mapping) |
| PRD §12 (success metrics) | §1 Success Criteria SC-1…SC-5 | Full |
| PRD §14 dep "deduct/refund API" | ADR-2 reframes to read-only count-based (Open Q4) | Full — divergence is explicit and justified, not a silent drop |
| PRD §16 Q5 (proactive fallback warning) | §5 item 5 — default = surface bucket in success toast | Partial — deferred to PM/Design, FE-only message |
Summary: 13 of 18 PRD elements Full, 5 Partial (each Partial is gated by a named open question, not a silent gap), 0 Missing. 0 RFC decisions without PRD justification — every §2 ADR traces back to a PRD section via the Detail 1.A reverse table. The PRD's "deduction/refund" framing (§14, LITE-S04/S05) is the one place PRD and RFC diverge, and the RFC handles it correctly by explicitly reframing it (ADR-2 + the §1 architectural-clarification callout) rather than silently dropping it.
Scorecard
Full-Stack Scorecard (18 categories)
Merged categories cite both layers; weakest layer drags the score.
| # | Category | Source | Score | Evidence-Based Rationale |
|---|---|---|---|---|
| 1 | PRT — PRD Traceability | Merged | 9.0 | FE+BE: explicit bidirectional matrix (Detail 1.A forward+reverse), per-story change map (Detail 1.C) maps every LITE-S0x to files + verifiable ACs; PRD §-coverage table; the one PRD/RFC divergence (deduct/refund) is reconciled explicitly via ADR-2, not dropped. |
| 2 | TDC — Technical Decisions | Merged | 8.0 | BE: 8 ADRs (Detail 1.B) each with chosen option + rejected alternatives + rationale tied to verified code. FE: representation/endpoint decisions resolved. Held below 9 because Open Q9 (chunk-7 restructure) is an explicit unresolved A/B decision (REV-4) and three inputs are external-blocked. |
| 3 | CNT — Contract Specificity (FE) | FE | 7.0 | §2.A UI contract + §2.B fetching strategy ($fetch/Kong, fetchPermissionsCrsById/fetchCompanyRoleList) + §2.C state matrix are concrete and grounded. Below 8 because the counter/grey-out component contract depends on the unspecified is_inbox marker derivation (REV-5) and the Figma frame is absent (REV-6). |
| 4 | SCB — Scope Boundaries (FE) | FE | 8.5 | Named files for every FE change (FeaturePermission.vue, permissionStore.ts, FieldRoles.vue, EditUsers.vue, InviteUsers.vue); §2.I + §1 Out-of-Scope explicit (no mobile, no qontak-billing, no new access level). Enhancement sub-type's highest-weight category — strong. |
| 5 | DEP — Dependencies (FE) | FE | 7.5 | Dependency table names owning team + blocking status for each; FE component lib pinned (@mekari/pixel3 v1.3.7, verified in package.json). Three deps are unresolved-but-flagged blockers (correct behavior; can't score 9 with open blockers). |
| 6 | NFS — Non-Functional Specificity (FE) | FE | 6.5 | Performance budget quantified (≤500ms quota p95, ≤1s invite p95, SC-4). a11y §3.E thin (relies on pixel3 defaults; no keyboard-flow/focus spec for the new counter interaction). No browser matrix, no bundle delta. Adequate for an enhancement reusing existing components, but not deep. |
| 7 | TPS — Test Plan Specificity (FE) | FE | 7.5 | §4.C + per-chunk acceptance criteria name vitest specs (counter increments, 21st blocked, inbox disabled) mirroring useRoles.spec.ts; BE go-test scenarios per branch. Integration-across-API-boundary tests are implied (mocked ModPanel) but no explicit FE↔BE contract test named. |
| 8 | DMS — Data Model & Schema (BE) | BE | 8.5 | §2.3 full DDL: consumed_bucket VARCHAR(16) NOT NULL DEFAULT 'standard' CHECK IN (...), partial index idx_users_company_bucket justified by the COUNT query, up/down migration, backfill-by-default. sqlc query list enumerated. Verified no such column exists today. No new tables (correct — Lite role is a row). |
| 9 | ACV — API Contract & Versioning (BE) | BE | 8.0 | §2.4 endpoint table: method/path/status/change/request/response per endpoint; §3.B error catalog with exact resp_desc.en + HTTP code; error→status mapping pinned (422 via ErrUnprocessableEntity, verified). Held below 9 by REV-1 (Lite component code strings) and REV-5 (is_inbox marker producer) leaving two response contracts under-pinned. |
| 10 | DIC — Data Integrity & Consistency (BE) | BE | 8.5 | §2.D integrity matrix + the count-model boundary corrections: same-bucket no-op (AC-2), CountUserByBucketExcluding (AC-3), single WithTx tx around role + bucket write, provisioning idempotency guard. The R1 self-review's tx/exclude-self fixes are the strongest evidence here and are verified against the lock-less/tx-less reality. |
| 11 | FMC — Failure Mode Coverage | Merged | 8.0 | BE: §3.A catalog (ModPanel-down 503 fail-safe, missing components → lite_quota=0, provisioning redelivery, tx rollback, 422 bypass) + §3.A.1 branch catalog. FE: §2.C state matrix + §3.C message catalog. Cross-layer: FE reads the exact resp_desc.en/customMessages shape the BE emits (verified). Below 8.5 by REV-7 (no pinned timeout/retry numbers) and REV-8 (no FE 429 string for role-change). |
| 12 | CSS — Concurrency & Scaling (BE) | BE | 8.0 | §2.E collision map names every shared-resource race + resolution: invite lock (existing, verified), role-change lock (new wiring, correctly flagged), provisioning idempotency, cache-vs-live-count. The headline insight — role change can over-provision exactly like invite but has no lock today — is correct and verified. QPS not quantified (acceptable, not a capacity RFC). |
| 13 | SAS — Security & Authorization (BE) | BE | 8.0 | §3 OWASP table: existing SsoAuth+PermissionCheck on all routes (verified), server-side ≤20/no-inbox validator (never trust UI), sqlc parameterized queries, fail-safe on ModPanel down, no-PII-at-Info logging. Ownership check inherits existing middleware. Strong for a new-feature reusing a hardened auth layer. |
| 14 | ROL — Rollout & Rollback | Merged | 8.0 | FE: flag-gated (lite_seats_enabled), §4.B config contract. BE: §2.3 migration is additive/reversible, §4.E rollback recipe (flag OFF → revert PR → drop column). Cross: §4.A compatibility matrix with all 4 BE/FE states, deploy order specified (BE before FE, migration first). No "No" in the matrix. Stage scheduling delegated to delivery (acceptable for a technical RFC). |
| 15 | OBS — Observability | Merged | 7.5 | BE: §3 maps every PRD §11 event to a concrete slog field/span tag + the two PRD alerts (count>50/1h, p95>1s). FE: error toasts/banners. Honest correction that there is no event bus today (events → structured logs/Datadog). Below 8 because exact metric names are "follow the slog convention" rather than fully enumerated, and no distributed FE→BE trace correlation id is specified. |
| 16 | SBC — Service Boundary & Coupling (BE) | BE | 8.5 | §2.F.1 responsibility matrix + §2.0 contracts table: logic lives in launchpad (REST + new consumer pod), ModPanel read-only, no new sync cross-service calls, CRS sync unchanged. "What's new vs reused" is explicit per artifact. |
| 17 | CPA — Pattern Alignment | Merged | 8.5 | BE: §2.0 Patterns-to-Follow table maps each concern to a reference file (sqlc, heimdall client, WithTx, NewErrorResponse, slog) and flags the one deliberate deviation (lock extracted to shared helper) with justification. FE: pixel3/Pinia/$fetch/vee-validate patterns mirrored; honest "no i18n, hardcoded EN" note. No silent new pattern. |
| 18 | CDG — Compliance & Data Governance (BE) | BE | N/A | §3.D: no new PII; consumed_bucket is non-PII; existing *_encrypted columns unchanged. No compliance trigger introduced by this RFC (it manipulates role/quota metadata, not personal data). Correctly marked N/A. |
Resource & Cost Advisory (non-blocking)
- §4.F: one cached ModPanel read + one indexed COUNT per invite/role-change (marginal on existing fleet), one
VARCHAR(16)column + one partial index, one new lightweight Kafka consumer pod, no new external calls. Reasonable and non-blocking. Route the new consumer-pod footprint to infra planning; it does not affect readiness.
Decision Closure Assessment
Decision Index
| # | Decision | Status | Critical Gaps |
|---|---|---|---|
| ADR-1 | Lite User = company_roles row (user_access='lite'), not a new access level | Resolved | none |
| ADR-2 | Count-based per-bucket quota (no Billing deduct/refund ledger) | Resolved | depends on Billing confirmation (Open Q4) but the design is fully specified for the count-based path |
| ADR-3 | consumed_bucket stored, not inferred | Resolved | none |
| ADR-4 | Flag via Kafka consumer + synchronous company_features read | Partial | event schema undefined (REV-2) |
| ADR-5 | Re-eval + re-bucket in tx under a shared lock newly applied to role-change | Partial | implementation path (refactor Update vs route via AssignRole) is open (REV-4 / Open Q9) |
| ADR-6 | Inbox exclusion = code-constant blocklist on permission_key | Partial | blocklist content undefined (REV-3); is_inbox marker derivation unspecified (REV-5) |
| ADR-7 | 20-key cap enforced at API (422) + FE counter | Resolved | none |
| ADR-8 | Extend existing endpoints; no new routes | Resolved | none |
Aggregate: 5 of 8 decisions Resolved, 3 Partial (ADR-4, ADR-5, ADR-6), 0 Dangling.
Decision: ADR-1 — Lite User represented as a company_roles row
Status: Resolved
What was decided: Lite User is a company_roles row with user_access='lite', is_default=true, name='Lite User' (Detail 1.B, A-1).
Alternatives considered: new access-level enum value in extdata; dedicated lite_roles table — both rejected with specific reasons (PRD §5.3 forbids a new access level; a new table duplicates role/permission machinery and breaks FieldRoles.vue/AssignRole).
Grounding: company_roles (is_default, user_access, no is_system_role) verified in db/schema.sql; extdata/role_type.go enum verified to have no lite value. References CreateDefaultCompanyRole, GetCompanyRoleByDefaultAndUserAccess.
Interface specification: Fully specified — the row shape and the provisioning query are named.
Failure handling: "system role / non-deletable" enforced by a user_access='lite' delete guard (422), since no is_system_role column exists. The PRD models is_system_role=true; the RFC's substitution is flagged as Open Q6/§5 item 6 for confirmation.
Challenge results:
- Scale: one row per CID — trivially scalable.
- Reversibility: high — it is data, not schema; deleting the row reverts.
- Consistency: consistent with ADR-7/ADR-8.
- Agent implementability: yes — agent can write the provisioning insert from the named query.
Gaps and suggestions: The only soft spot is the is_system_role substitution; confirm the delete-guard approach is acceptable vs adding the column (already tracked as §5 item 6).
Decision: ADR-2 — Count-based per-bucket quota
Status: Resolved
What was decided: available = entitlement − COUNT(users in bucket); "deduct a Lite seat" = create user with consumed_bucket='lite'; "refund" = re-bucket so the COUNT recomputes. No per-seat deduction call to Billing.
Alternatives considered: explicit deduction/refund ledger calls to Billing — rejected because launchpad has no such call today (verified: validate_user_quota.go counts) and a ledger is Billing-owned, out of phase-1 scope.
Grounding: validate_user_quota.go independently re-verified — it is exactly CountUser(companyID) >= InitialCredit+AdditionalCredit. The reframing of the PRD's "deduct/refund" language is the single most important architectural clarification in the RFC and it is correct.
Interface specification: CountUserByBucket / CountUserByBucketExcluding queries specified (§2.3).
Failure handling: ModPanel-down → 503 fail-safe, no create.
Challenge results:
- Scale: COUNT on
idx_users_company_bucket(partial, company-scoped) is cheap. - Reversibility: high — no external ledger to unwind.
- Consistency: consistent; resolves the PRD framing ambiguity.
- Agent implementability: yes for the count path. The only residual is Open Q4 (Billing must confirm it does not require launchpad to report per-bucket consumption back) — a confirmation, not a design gap.
Gaps and suggestions: Close Open Q4 with Billing; if a deduct/refund API is later mandated, it is a clean follow-up.
Decision: ADR-4 — Flag delivery via Kafka consumer + synchronous company_features read
Status: Partial
What was decided: A consumer on bifrost.company.settings.events.v1 provisions the Lite role on activation; a synchronous GetCompanyFeatureByCode read gates enforcement at decision points.
Alternatives considered: poll Billing on every invite; FE-only flag — both rejected with reasons.
Grounding: topic verified (internal/kafka/topics.go); features/company_features tables exist but have no query layer (verified — only nav_items.sql matches "feature") so GetCompanyFeatureByCode is correctly tagged new.
Interface specification: The synchronous read query is specified. The event payload schema is not ({cid, feature, state} is a placeholder) — REV-2.
Failure handling: §2.F — DB write failure → offset not committed → redelivery back-fills; lazy-provisioning fallback if the flag arrives only as a company_features row.
Challenge results:
- Agent implementability: the synchronous-read + lazy-provisioning path is implementable now; the event-driven consumer is not until the schema is supplied. The RFC's dual-path design is a good hedge.
Gaps and suggestions: Obtain the event schema (field names, types, the state enum values) from Billing/Platform — this is Open Q3 / REV-2. Until then, build the synchronous-read path and stub the consumer behind the confirmed schema.
Decision: ADR-5 — Re-eval + re-bucket in a transaction under a newly-applied shared lock
Status: Partial
What was decided: Role change re-evaluates quota and re-buckets, serialized under the same kind of Redis lock used by invite — extracted into a shared helper and newly applied to the role-change handlers; the role + consumed_bucket writes go in a single WithTx tx.
Alternatives considered: best-effort, no lock — rejected because a cross-bucket role change can over-provision under concurrency exactly like invite.
Grounding: Independently verified — assign_role.go has no SetNx/BeginTx/WithTx/ValidateUserQuota (0 matches); update.go writes the role change in go func() goroutines with a sync.WaitGroup and no transaction; the Redis lock exists only in SsoInvite (handler lines 128/131). The RFC's R1 self-correction (re-tagging this as new wiring, not reuse) is accurate.
Interface specification: CountUserByBucketExcluding, the same-bucket no-op branch, and the shared-lock helper (chunk 3b) are specified.
Failure handling: tx rollback on quota-adjust failure (LITE-S05/ERR-2); revert on exhausted target (ERR-1).
Challenge results:
- Reversibility: the restructure of
update.gois the least reversible chunk (it rewrites a concurrent code path); rework cost is real. - Agent implementability: the agent must choose between (a) refactoring
Updateto be transactional/serialized and (b) routing FE role changes throughAssignRoleand hardening it. This A/B choice is left open (Open Q9). An agent will guess; the author should decide — REV-4.
Gaps and suggestions: Close Open Q9 by picking (a) or (b). Recommend (b) if Update's goroutine fan-out does other independent work that should not be serialized; recommend (a) if the role write is the only mutation, to avoid a second code path. State which.
Decision: ADR-6 — Inbox exclusion via code-constant blocklist
Status: Partial
What was decided: A code-constant blocklist keyed on permission_component_codes.permission_key, enforced at API (422) + FE disable.
Alternatives considered: DB column flag on permissions — rejected (a code constant is reviewable/versioned with the release).
Grounding: verified — no inbox classification exists today on permissions/permission_component_codes; the permission_key vocabulary (GetPermissionComponentCodesByKey, migration 20250812044119) is real and is the right key namespace.
Interface specification: The validator (count(enabled) ≤ 20 AND ∀ key ∉ blocklist) is specified. The blocklist content is undefined (Open Q1 / REV-3) and the is_inbox marker the FE consumes (added to GET /permissions_crs) has no specified derivation rule (REV-5).
Failure handling: 422 on bypass.
Challenge results:
- Agent implementability: the validator skeleton is implementable; an empty/guessed blocklist silently passes every inbox key, defeating the guard rail and SC-2. The agent cannot invent the correct keys.
Gaps and suggestions: Supply the definitive inbox permission_key list (Open Q1) and specify how the permissions_crs is_inbox marker is computed from it (same constant, presumably) so FE and BE share one source of truth — REV-3 + REV-5.
UI State Audit
| Component | Loading | Empty | Error | Partial | Success | Assessment |
|---|---|---|---|---|---|---|
Lite config panel (FeaturePermission.vue) | defined | defined | defined | n/a (single source) | defined | 4/4 applicable — §2.C: MpSkeleton / "0/20" / "Failed to load…retry" / saved toast |
Invite (InviteUsers.vue + FieldRoles.vue) | defined | n/a | defined | n/a | defined | 3/3 applicable — submit spinner / quota banner / success toast |
Edit user role (EditUsers.vue) | defined | n/a | defined (revert + banner) | n/a | defined | 3/3 — but no explicit 429 (lock-contention) state for role change (REV-8) |
Summary: All data-driven components have their applicable states defined via §2.C. The single gap is a missing FE 429/lock-contention state for the newly-locked role-change path.
Performance Budget Check
| Metric | Target | Current Baseline | Source | Assessment |
|---|---|---|---|---|
| Quota eval latency | ≤ 500ms p95 | not stated | PRD §6 / SC-4 | adequate target; baseline unstated (load test to validate, §5 item 8) |
| Invite/role-create | ≤ 1s p95 | not stated | PRD §6 / SC-4 | adequate target |
| Bundle size delta | not stated | — | — | NO FE BUNDLE BUDGET — acceptable: enhancement reuses existing pixel3 components, no new package |
Accessibility Review
| Aspect | Specified? | Details | Assessment |
|---|---|---|---|
| Keyboard navigation flow | no | relies on pixel3 defaults | incomplete — new counter interaction (block 21st check) has no described keyboard behavior |
| Focus management | no | — | not addressed (no modal introduced) |
| ARIA labels | partial | §3.E: disabled checkboxes expose tooltip text to SR (pixel3 default) | adequate-by-reuse |
| Heading hierarchy | no | — | not addressed (reuses existing settings page) |
| Color contrast | no | grey-out state contrast not verified | incomplete — greyed inbox keys must still meet contrast for the disabled+tooltip pattern |
| Motion sensitivity | n/a | no animation introduced | n/a |
| Screen reader behavior | partial | tooltip text exposed | adequate-by-reuse |
The a11y posture is "inherit pixel3 defaults," reasonable for an enhancement but the new counter/grey-out interaction deserves an explicit keyboard + contrast note once the Figma frame lands.
Pattern Alignment Check
| Pattern | RFC Approach | Assessment |
|---|---|---|
| BE quota check (count vs entitlement) | extends validate_user_quota.go per bucket | follows — verified |
| BE Redis lock | extends — extracts SsoInvite lock to shared helper, applies to role-change | deliberate deviation, justified (the one flagged deviation) |
BE transaction (WithTx) | follows store.go | follows |
| BE sqlc / error envelope / slog | follows named reference files | follows |
FE Pinia / $fetch-Kong / vee-validate / pixel3 | follows existing composables + components | follows |
| FE i18n | hardcoded EN (repo has no i18n) | follows existing (honestly flagged) |
| Existing analytics events | PRD §11 events realized as slog/Datadog (no bus today) | preserved-by-mapping; honestly corrected |
No silent parallel system. The single deviation (shared lock helper) is explicit and justified.
Data Integrity Deep-Dive
| Write Path | Transaction Scope | Partial Failure Behavior | Idempotency Key | Consistency Guarantee | Duplicate Handling |
|---|---|---|---|---|---|
| Invite Lite/standard user | BEGIN; CreateUser(consumed_bucket); InsertUserRole; COMMIT (§2.2) | tx rollback; nothing created if any step fails | Redis lock:user_invite:{cid} (10s) prevents concurrent over-provision | strong (COUNT live from Postgres inside lock) | lock + count-inside-lock |
| Role change re-bucket | single WithTx tx: role write + UpdateUserConsumedBucket (chunk 7) | rollback → role unchanged (LITE-S05/ERR-2) | newly-applied shared lock (verified absent today) | strong, under lock | exclude-self count avoids double-count (AC-3) |
| Provisioning (consumer) | single insert via CreateDefaultCompanyRole | offset not committed → Kafka redelivery back-fills | GetCompanyRoleByDefaultAndUserAccess(cid,'lite') pre-check | eventual (event-driven) | idempotency lookup before insert |
Strong across all three write paths. The only residual risk is chunk-7 implementation choice (REV-4), not the integrity model itself.
Concurrency Collision Map
| # | Shared Resource | Writers | Collision Scenario | Resolution Mechanism | Lock Failure Behavior | Assessment |
|---|---|---|---|---|---|---|
| 1 | seat count per CID (invite) | two concurrent invites | both read last seat, both create | existing SetNx lock + count-inside-lock (verified) | 429 ErrTooManyConcurrentRequests | adequate — existing, unchanged |
| 2 | seat count per CID (role change) | two role changes / invite + role change | both cross buckets, over-provision | new shared lock applied to role-change handlers + WithTx | 429 — but no FE string for role-change 429 (REV-8) | adequate BE; minor FE gap |
| 3 | Lite role row per CID | duplicate Kafka events | two consumers provision twice | idempotency lookup before insert | no-op | adequate |
| 4 | billing cache vs live count | cache read vs new seat | stale entitlement | entitlement cached, COUNT always live | n/a | adequate |
The standout (and verified) insight is collision #2: the RFC correctly identifies that role change has the same over-provision exposure as invite but no lock today.
API Contract Completeness Check
| Endpoint | Request Schema | Response Schema | Error Taxonomy | Auth Spec | Idempotency | Example Payloads | Assessment |
|---|---|---|---|---|---|---|---|
POST /iag/v1/users/sso_invite | complete (ApiUserInviteParam, unchanged) | partial (200 user) | complete (422/422/503 §3.B) | inherited SsoAuth+PermissionCheck (verified) | lock-based | partial | 5/6 |
PATCH /iag/v1/company_roles/{id} | complete ({name,user_access,permissions[],description}) | complete | complete (two 422s) | inherited | n/a | partial | 5/6 |
PUT /iag/v1/users/{user_id}/roles/{role_id} | path params | partial | complete (422/500) | inherited | new lock | missing | 4/6 |
PUT /iag/v1/users/{sso_id} | {…,role_crs_id} | partial | complete | inherited | new lock | missing | 4/6 |
PUT /iag/v1/company_roles/delete/{id} | path param | n/a | complete (422 delete guard) | inherited | n/a | n/a | complete |
GET /iag/v1/billings/{company_id}/unified_billing | path param | partial — adds lite_initial_credit/lite_additional_credit but the source component codes unconfirmed (REV-1) | n/a | inherited | n/a | partial | 3/6 |
GET /iag/v1/permissions_crs | — | partial — adds is_inbox marker, derivation unspecified (REV-5) | n/a | inherited | no | 2/6 |
Contracts are mostly consumer-ready; the two soft endpoints (billing read, permissions_crs) are soft precisely because of the two blocking external inputs.
Async Job / Event Consumer Spec
| Job/Consumer | Trigger | Input Shape | Retry Policy | DLQ | Concurrency Limit | Idempotency Key | Timeout | Assessment |
|---|---|---|---|---|---|---|---|---|
LiteSeatsConsumer | Kafka bifrost.company.settings.events.v1, feature=lite_seats_enabled,state=ON | specified as placeholder {cid,feature,state} — schema TBD (REV-2) | offset-not-committed → redelivery | not named (relies on Kafka redelivery, no explicit DLQ) | not specified | GetCompanyRoleByDefaultAndUserAccess(cid,'lite') | not specified | 4/7 — idempotency strong; input schema, DLQ, concurrency, timeout open |
The consumer's idempotency design is solid, but input schema (REV-2), DLQ, per-message timeout, and concurrency limit are unspecified. Flagged for FMC/DIC per the rubric.
Compliance Trigger Check
| Trigger | Found? | Data Location | Classification | Assessment |
|---|---|---|---|---|
| PII (name/email/phone/...) | no (new) | existing users.*_encrypted unchanged | n/a | RFC adds no PII; §3.D explicit |
| Payment data | no | — | — | n/a |
| Health data | no | — | — | n/a |
| User content w/ retention | no | — | — | n/a |
| Auth/session data | no | reuses existing SSO/middleware | n/a | no new auth data |
| Cross-border transfer | no | — | — | n/a |
CDG Status: N/A — no compliance triggers found. consumed_bucket is role/quota metadata, not personal data.
Cross-Layer Contract Verification
| Endpoint | Backend Response Schema | Frontend Expected Schema | Match? | Gaps |
|---|---|---|---|---|
| invite/role-change error | resp_code + resp_desc.{id,en} (NewErrorResponse) | error.value.data?.customMessages?.message || resp_desc?.en (useUsers.ts, verified) | Yes | none — existing envelope reused |
PATCH /company_roles/{id} | {name,user_access,permissions[],description} | useRoles.updateRole sends exactly this | Yes | none |
GET …/unified_billing | snake_case lite_initial_credit/lite_additional_credit | billing store reads snake_case | Yes (shape) | source component codes unconfirmed (REV-1) — shape aligns, values may be empty |
GET /permissions_crs | adds is_inbox marker | FeaturePermission.vue reads marker for :is-disabled | Partial | additive + back-compatible, but BE derivation of is_inbox unspecified (REV-5) |
Checks performed:
- Field name casing consistent (snake_case BE; FE reads snake_case directly — verified, no transform mismatch)
- Nullability aligned (missing Lite components →
lite_quota=0, FE handles standard-only) - Error response shape matched (verified FE reads exact BE envelope)
- Pagination — n/a (no new list pagination)
- Auth token format — inherited, unchanged
Mismatches found: 0 hard mismatches (no ROL cap triggered). Two additive contracts (REV-1, REV-5) are under-specified on value/derivation, not shape.
Cross-Layer Rollout Compatibility Matrix
| Scenario | Frontend | Backend | Works? | Notes |
|---|---|---|---|---|
| Pre-deploy (baseline) | Old | Old | Yes | Current state |
| Backend first | Old | New (flag OFF) | Yes | byte-identical to today (SC-5); migration additive |
| Backend first | Old | New (flag ON) | Yes | Lite role exists; BE validators enforce ≤20/no-inbox; FE counter just absent |
| Frontend first | New | Old | Partial | FE flag composable reads a flag the old BE may not surface — but FE flag defaults OFF, so Lite UI stays hidden; safe |
| Both deployed | New | New | Yes | Target state |
| Backend rollback | New | Old (rolled back) | Yes | column additive/harmless; flag OFF reverts behavior |
| Frontend rollback | Old (rolled back) | New | Yes | BE validators remain source of truth |
Deploy order: Backend first (explicitly specified §4.A: BE before FE, migration first). Incompatible scenarios: 0. ROL is not capped.
End-to-End Data Flow
Flow: Invite a Lite User (Lite Quota available)
Admin clicks Invite (role=Lite User)
→ FE: InviteUsers.vue + FieldRoles.vue → useUsers.inviteUser ($fetch → Kong)
→ API: POST /iag/v1/users/sso_invite {RoleId=Lite, email, ...}
→ BE: user_handler.SsoInvite → SetNx lock:user_invite:{cid}
→ BillingService.EvaluateQuotaForRole(cid, role=lite)
→ Redis GET Package:Unified:{ext_cid} (cache) / ModPanel unified_subscription (miss)
→ DB COUNT(consumed_bucket='lite'), COUNT('standard') [excludes deleted/consultant]
→ bucket=lite (lite_used < lite_quota)
→ DB: BEGIN; CreateUser(consumed_bucket='lite'); InsertUserRole; COMMIT
→ Side effects: invitation email; slog invite_quota_decision{cid,role,bucket}; DEL lock
→ Response: 200 {user, role="Lite User"}
→ FE: store update → user list shows "Lite User"; success toast
Gaps in flow: none — fully traceable from §2.2 + §2.0. The only undetermined link is the ModPanel component-code match inside EvaluateQuotaForRole (REV-1).
Flow: Change role Lite → Member (re-bucket)
Admin changes role in EditUsers.vue → useUsers.updateUser → PUT /iag/v1/users/{sso_id} {role_crs_id}
→ BE: [chunk 7] acquire shared lock:user_invite:{cid}
→ compute new required bucket; if == consumed_bucket → NO-OP (save role only, AC-2)
→ else EvaluateQuotaForRole with CountUserByBucketExcluding(this user) [AC-3]
→ WithTx: UpdateUserConsumedBucket + role write; rollback on failure (ERR-2)
→ Response: 200 / 422 "Standard quota exhausted — cannot upgrade this user" (ERR-1)
→ FE: success toast / revert selection + banner
Gaps in flow: the BE handler that owns this is an open A/B decision — refactor Update (today goroutine+non-tx, verified) vs route through AssignRole (REV-4 / Open Q9). The flow is specified; the host code path is not chosen.
Agentic Readiness Deep-Dive
Vague Word Audit
| # | Word/Phrase | Location | Impact | Concrete Replacement |
|---|---|---|---|---|
| 1 | {cid, feature, state} "schema TBD" | §2.F consumer | agent guesses event field names/types | supply the real Kafka event schema (REV-2) |
| 2 | is_inbox marker (no derivation) | §2.4 /permissions_crs | agent guesses how BE computes the marker | specify: marker = permission_key ∈ inbox blocklist constant (REV-5) |
| 3 | "metric/log names follow the slog convention" | §3 OBS | agent invents field names | enumerate the final metric/field names |
Total material vague phrases in spec sections: 3 — each already tracked as an open question or finding. The RFC is otherwise notably free of hand-wave ("handle gracefully" does not appear; every error has a pinned code + string).
Dangling Alternatives
| # | Alternatives | Location | Impact |
|---|---|---|---|
| 1 | refactor Update or route through AssignRole | Open Q9 / chunk 7 / ADR-5 | agent must choose the host code path for the highest-risk chunk (REV-4) |
Total dangling alternatives: 1 (and it is explicitly surfaced as the highest-risk chunk, not buried).
Task Decomposition Assessment
| Chunk | Acceptance Criteria | Assessment |
|---|---|---|
| 1–10 (§4.D Agent Execution Plan) | per-chunk files + commands + acceptance criteria + deploy order | verifiable — this is a model task manifest; chunks 1/5/6/9 carry the blocking-dependency caveats inline |
The RFC ships its own ordered, command-level execution plan with explicit chunk dependencies and a highest-risk-chunk callout (chunk 7). This is materially better than most RFCs at this stage.
Strengths
- Code-grounding discipline (§2.0 Source Verification). Every "reuse/extend" claim is backed by a quoted code anchor, and this reviewer independently re-verified the load-bearing ones against the live
qontak-launchpadcheckout: count-basedvalidate_user_quota.go, theSsoInvite-only Redis lock, the lock-less +go func()/non-tx role-change paths (assign_role.go,update.go), theQONTAKCHAT-User-*component parsing, and theErrUnprocessableEntity→422 /ErrBadRequestCustomDesc→400 constructors. They all check out. This is the single biggest reason the RFC is agent-ready. - Complete, bidirectional PRD traceability (Detail 1.A + 1.C). Every PRD story LITE-S01…S06 + LITE-S01-NEG maps forward to files/endpoints and reverse to PRD ACs, with verifiable go-test/vitest acceptance criteria per chunk. The one PRD/RFC divergence (deduct/refund framing) is reconciled explicitly via ADR-2, not silently dropped.
- Correctness traps pre-solved (DIC). The R1 self-review caught and fixed the count-model boundary bugs (same-bucket no-op LITE-S05/AC-2, exclude-self
CountUserByBucketExcludingAC-3), the transaction boundary for re-bucketing, and the 422-vs-400 error-code mapping — exactly the things an agent would otherwise get wrong on the first pass.
Biggest Gaps
- Three blocking external inputs cannot be invented by an agent (Open Q1/Q2/Q3 → REV-1/2/3). The exact Lite ModPanel component code strings (chunk 1, §2.4 billing read), the
lite_seats_enabledKafka event schema (chunk 5, §2.F), and the inboxpermission_keyblocklist (chunks 6/9, ADR-6) are all undefined. An agent will hardcode placeholders that are almost certainly wrong, and an empty inbox blocklist silently defeats the SC-2 guard rail. - Highest-risk chunk left as an open A/B decision (Open Q9 → REV-4). The role-change restructure must add a lock + transaction to code that verifiably has neither (
update.gogoroutine+non-tx,assign_role.golock-less). The RFC correctly flags this but does not choose between refactoringUpdateand routing throughAssignRole— an architecture decision an agent should not be making. permissions_crsis_inboxmarker has no producer rule (§2.4 → REV-5). The FE grey-out depends on a new BE marker whose derivation is unspecified; it is coupled to the (undefined) inbox blocklist and will block the FE inbox-disable behavior even once the Figma frame lands.
Priority Actions
Ordered by impact on implementation readiness.
- Open Q2 / REV-1 (billing read, chunk 1) — supply the confirmed Lite ModPanel component
Codestrings (the real analogs ofQONTAKCHAT-User-Initial/-Additional). Without the exact strings,GetBillingInfoBySsoIDmatches nothing andlite_quotais always 0. This is a one-line input that unblocks the foundational chunk. - Open Q1 + REV-5 (inbox guard, chunks 6/9) — produce the definitive inbox
permission_keyblocklist constant AND state that thepermissions_crsis_inboxmarker is computed aspermission_key ∈ blocklist, so FE and BE share one source of truth. This unblocks the guard rail (SC-2) and the FE grey-out contract together. - Open Q3 / REV-2 (consumer, chunk 5) — obtain the
bifrost.company.settings.events.v1event schema (field names, types, thestateenum). Until then, build only the synchronouscompany_featuresread + lazy-provisioning path and stub the consumer behind the confirmed schema. - Open Q9 / REV-4 (chunk 7) — decide (a) refactor
Updateto a single transactional, lock-serialized role+bucket write, or (b) route FE edit-user role changes throughAssignRoleand harden that. State the choice in ADR-5 so the agent does not pick the architecture for the highest-risk chunk.
Backend Contract Addendum
Endpoint Contract Details
| Endpoint | Method/Path | AuthZ | Request Contract | Response Contract | Error Contract | Idempotency/Versioning | Status |
|---|---|---|---|---|---|---|---|
| Invite | POST /iag/v1/users/sso_invite | SsoAuth+PermissionCheck (verified) | ApiUserInviteParam{RoleId,…} unchanged | 200 user + consumed_bucket set | 422/422/503 (§3.B) | Redis lock (existing) | Complete |
| Role config | PATCH /iag/v1/company_roles/{id} | same | {name,user_access,permissions[],description} | 200 | 422 >20, 422 inbox (§3.B) | n/a | Complete |
| Role change | PUT /iag/v1/users/{user_id}/roles/{role_id} · PUT /iag/v1/users/{sso_id} | same | path / {role_crs_id} | 200 | 422 ERR-1, 500 ERR-2, (429 lock — FE string missing, REV-8) | new shared lock + WithTx | Missing [host-path A/B decision REV-4] |
| Role delete | PUT /iag/v1/company_roles/delete/{id} | same | path | n/a | 422 delete guard | n/a | Complete |
| Billing read | GET /iag/v1/billings/{company_id}/unified_billing | same | path | 200 + lite_initial_credit/lite_additional_credit | n/a | additive | Missing [component code strings REV-1] |
| Permission tree | GET /iag/v1/permissions_crs | same | — | 200 + is_inbox marker | n/a | additive | Missing [marker derivation REV-5] |
Database Changes Details
| Change | Table/Entity | DDL / Shape Diff | Data Migration Plan | Rollback Plan | Compatibility Window | Status |
|---|---|---|---|---|---|---|
| add column + index | users | consumed_bucket VARCHAR(16) NOT NULL DEFAULT 'standard' CHECK IN ('standard','lite'); partial idx_users_company_bucket (company_id, consumed_bucket) WHERE deleted_at IS NULL | backfill via DEFAULT (no Lite users pre-launch) | down: DROP INDEX + DROP COLUMN (additive, harmless) | column independent of flag; ships first | Complete |
| new queries | users.sql / company_features.sql | CountUserByBucket, CountUserByBucketExcluding, UpdateUserConsumedBucket, GetCompanyFeatureByCode | sqlc regenerate | revert query file | n/a | Complete |
| Lite role row | company_roles | data only (no DDL) | provisioned by consumer/lazy | delete row (guarded) | per-CID | Complete |
Implementation Readiness Checklist
Unblocked (agent can proceed)
- PRD → RFC traceability matrix complete (bidirectional, Detail 1.A/1.C)
- Technical decisions resolved with alternatives rejected (5/8 Resolved; 3 Partial gated on named open questions)
- Failure modes handled with error message catalog (§3.A/§3.B/§3.C)
- Configuration contract (§4.B: flag, cap=20, component codes, blocklist)
- Pattern alignment verified (§2.0 patterns table; one justified deviation)
- Rollout plan with feature flag + rollback (§4.A/§4.E)
- Observability mapping defined (§3, PRD §11 events → slog/Datadog)
- Task decomposition with acceptance criteria per chunk (§4.D)
- Schema at DDL precision (§2.3)
- Transaction boundaries + idempotency per write path (§2.D, Data Integrity Deep-Dive)
- Concurrency collision points listed with resolution (§2.E)
- Security: auth boundaries, validation, injection, fail-safe (§3 OWASP)
- Cross-layer contract verified — 0 hard mismatches
- Deploy order specified (BE first, migration first)
- Cross-layer rollout matrix — no unaddressed "No"
- End-to-end data flow documented
Blocked (must fix first)
- REV-1 — confirmed Lite ModPanel component code strings (chunk 1)
- REV-3 — inbox
permission_keyblocklist content (chunks 6/9) - REV-2 —
lite_seats_enabledevent schema (chunk 5 event path) - REV-4 — choose chunk-7 host path (
Updaterefactor vsAssignRole) - REV-5 —
permissions_crsis_inboxmarker derivation rule - REV-6 — Lite-config Figma frame (FE chunk 9 only)
Verdict: Fix 6 blockers first for FULL execution — but BE chunks 1 (shape only), 2, 3, 3b, 4, 7 (pending REV-4), 8 and FE chunk 10 can proceed now; chunks 1 (values), 5 (event path), 6 (validator content), 9 are gated.
Task Manifest
Verifying the RFC's own §4.D Agent Execution Plan — it is well-formed; reproduced with gating annotations.
| Order | Chunk | Files to Create/Modify | Acceptance Criteria | Dependencies |
|---|---|---|---|---|
| 1 | Lite entitlement read | get_billing_info_by_sso_id.go, billing_response.go | response carries Lite fields; missing → 0 | REV-1 (component code strings) for real values |
| 2 | consumed_bucket migration + queries | db/migration/*, db/query/users.sql, sqlc | column+index exist, reversible | None |
| 3 | Tiered evaluator (+ typed 422) | billings/*, user_handler.go, sso_invite.go | all 3.A.1 branches; 422/503 | Chunk 1, 2 |
| 3b | Shared lock helper | internal/pkg/* | invite unchanged; helper unit-tested | None |
| 4 | Feature-flag read | company_features.sql, service reader | flag per CID; OFF→standard | None |
| 5 | Provisioning consumer | consumer/lite_seats_provision.go, iConsumer.go | provisions one role; replay no-op | REV-2 (event schema) for event path |
| 6 | Role validator + delete guard | role_handler.go + service, blocklist constant | 422 on >20/inbox/delete-lite | REV-3/REV-5 (blocklist + marker) |
| 7 | Role-change re-bucket (restructure) | assign_role.go, update.go | re-bucket in tx; no-op; rollback | Chunk 3, 3b; REV-4 (host path) |
| 8 | Swagger | swag annotations | docs/swagger.* regenerated | Chunks 1,3,6,7 |
| 9 | FE counter + inbox-disable | FeaturePermission.vue, permissionStore.ts, EditRoles.vue, specs | counter N/20; 21st blocked; inbox disabled | REV-6 (Figma), REV-5 (marker) |
| 10 | FE flag gate + dropdown + errors | useFeatureFlag, FieldRoles.vue, InviteUsers.vue, EditUsers.vue | Lite shown only flag ON; quota errors surfaced | None |
Dangling Decisions Log
| # | Decision | Location | Owner | Deadline |
|---|---|---|---|---|
| 1 | Chunk-7 host path: refactor Update vs route via AssignRole (REV-4) | Open Q9 / ADR-5 / chunk 7 | Bifrost Eng | before BE merge |
Note: this is the only true dangling decision. REV-1/2/3/5 are dangling inputs (external/owned-elsewhere), tracked as Open Questions, not architecture choices.
Open Questions
| # | Question | Category | Severity |
|---|---|---|---|
| 1 | Exact Lite ModPanel component Code strings? (REV-1) | ACV / DEP | Blocking |
| 2 | Inbox permission_key blocklist content + permissions_crs is_inbox derivation rule? (REV-3, REV-5) | SAS / ACV / CNT | Blocking |
| 3 | lite_seats_enabled Kafka event schema (fields, types, state enum)? (REV-2) | DIC / FMC | Blocking |
| 4 | Chunk-7 host path — Update refactor or AssignRole routing? (REV-4) | TDC / DIC | Important |
| 5 | ModPanel quota-read timeout/retry/circuit-breaker numbers for the Lite path? (REV-7) | FMC | Nice-to-have |
| 6 | FE 429/lock-contention message for role-change flow? (REV-8) | FMC | Nice-to-have |
| 7 | Lite-config Figma frame (counter + inbox grey-out) — FE chunk 9 (REV-6) | CNT / NFS | Important (FE only) |
| 8 | Count-based model acceptable to Billing (no per-bucket report-back)? (Open Q4) | DIC | Important |
| 9 | is_system_role substitution via delete-guard acceptable vs adding column? (Open Q6) | DMS | Nice-to-have |
Evidence Notes
internal/app/service/billings/validate_user_quota.go— re-verified live: exactlyCountUser(companyID) >= InitialCredit+AdditionalCredit, no role awareness. Confirms ADR-2's count-based premise and the §1 PRD-reframing callout. Raised PRT/DIC/ADR-2 confidence to High.internal/app/service/users/assign_role.go+update.go— re-verified live:assign_role.gohas zeroSetNx/BeginTx/WithTx/ValidateUserQuotamatches;update.gousessync.WaitGroup+go func()with no transaction. Confirms the RFC's R1 grounding correction (ADR-5, Open Q9, chunk 7) is accurate, and is the basis for REV-4's severity.db/schema.sql—company_roleshasuser_access/is_default, nois_system_role;usershas noconsumed_bucket. Confirms A-1, ADR-1, ADR-3, §2.3 DDL.internal/app/service/billings/get_billing_info_by_sso_id.go— parsesQONTAKCHAT-User-Initial/-Additional. Confirms the parse pattern; the Lite analog strings are the REV-1 gap.internal/pkg/http/default_error.go—ErrUnprocessableEntity→422,ErrBadRequestCustomDesc→400 both present. Confirms §2.4/§3.B error-code pinning.- §6 Comment-log (RFC) — the author's R1 adversarial self-review; reconciled (not double-counted) per the SOP.
- Discussion/comment sections: §6 Comment-log treated as non-authoritative per the evidence-normalization rule, except where its corrections are already promoted into the authoritative spec body (ADR-5, §2.E, chunk 7, §3.B) — those are scored from the spec body, not the log.
Review History
| Cycle | Date | Reviewed RFC revision (last_updated / commit) | Score | Verdict | Findings open → fixed | Notes |
|---|---|---|---|---|---|---|
R1 | 2026-06-23 | last_updated: 2026-06-23 / working tree (uncommitted) | 8.0 | PROCEED with notes (HOLD on FE chunk 9 + blocking deps) | 8 open (3 blocker, 2 major, 3 minor), 0 fixed | First external review. Independently re-verified all load-bearing §2.0 grounding claims against live qontak-launchpad; all accurate. Reconciled with the RFC's own §6 R1 self-review (lock/tx correction) without double-counting. Score ceiling is the three open external blockers + missing Figma, not any score cap. |