Admin & CS tools¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/admin-and-cs-tools.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What admin-and-cs-tools is for¶
The invariant you must not break: no state-changing CS action executes
without an operator identity and a typed reason. Mutations are only
reachable through /api/v1/admin/cs/* with an admin JWT; a request without
a reason gets a 422; the impersonation token is meant to be structurally
incapable of mutating. Violating this makes money movements — force-charge
and force-refund touch the real charge rail — unattributable, and collapses
the compliance answer to "who did this and why" that the audit trail exists
to give.
(ADR-0050 line 108
+ §Consequences; CsActionsPanel.tsx header and reason-gated buttons.)
The capability breaks into twelve reader-facing features, all reached from the merchant admin app:
- Create a subscription for a customer manually — phone order, migration, or goodwill, without a storefront checkout (US-22.1)
- See exactly what a subscriber sees, without touching anything — a CS rep opens a read-only, 15-minute impersonation view of the customer's portal to diagnose issues (US-22.3)
- Create many subscriptions at once from a CSV — bulk import with preview-then-confirm for migrations and B2B onboarding (US-22.4)
- Charge or refund outside the schedule — force-charge (with optional amount override) or force-refund (full or partial), with a mandatory reason (US-22.5)
- Pin a merchant note to a subscription — internal context travels with the record (US-22.6)
- Manage a customer's allotment grants — view and revoke granted allotments from the subscription detail (US-22.7)
- View and edit subscription custom fields — CS corrects merchant-defined metadata in place (US-22.8)
- Enroll a B2B account, admin-only — B2B subscriptions are created from admin, never storefront checkout (US-22.9, per ADR-0023)
- Work the highest-impact problems first — a prioritized exception queue (failed charges, OOS renewals, reconciliation drift) grouped by type, with resolve/escalate/refund actions per row (US-21.3, US-12.1)
- Full context on one screen — the subscription detail view: state, plan, payment-method health, addresses, exceptions, and CS actions in one place (US-21.6)
- Complete activity history per subscription — a timeline of every event with who did it (merchant user, subscriber, system, webhook) (US-13.1)
- Answer "why can't this customer subscribe?" in seconds — a read-only eligibility-audit diagnostic that traces the rule chain with actual input values (US-26.10)
Four decisions carry the most weight:
- Impersonation is read-only and short-lived; mutation goes through a
separate audited namespace. The CS impersonation session (15 min,
non-renewable) is designed to never mutate; every state-changing CS action
requires the merchant-admin JWT on
/api/v1/admin/cs/*plus areasonfield (422 without), logged withoperator_id(ADR-0050). See the live-state note below — the traced code does not implement the read-only enforcement layer this decision specifies. - B2B enrollment is admin-owned, not checkout-owned. B2B subscriptions are created only via the admin enrollment surface (US-22.9); storefront checkout is explicitly not the B2B entry point (ADR-0023).
- Phase-1 RBAC: all merchant staff can act; accountability is the audit
trail, not a permission gate. Any authenticated merchant-staff user can
resolve exceptions and run CS actions;
actor_user_idis always written (exceptions.resolved_by+ typedlogEvent); the view-only-vs-resolving role split is deferred to Epic 29 (GH #1331 / synthesis #1333, commit 368139e3;routes/admin/exceptions/resolve.tsheader). - One event log is the audit substrate. Every action funnels through
logEvent()into theeventstable withactor_kind(merchant_user / subscriber / system / webhook_bc / webhook_processor); the admin Activity timeline (US-13.1) renders that same table — there is no separate audit store (embedded in ADR-0050 §Consequences; no standalone audit-trail ADR exists).
Canonical-framing attestation (operator-ratified 2026-07-02).
ADR-0050 is the
deciding decision for this domain's override pattern. On the admin app's
structure: the v2 left-rail Shell is the sole live routing surface
(App.tsx — Hive #1062 hard-launch removed the legacy AppShell; /
redirects unconditionally to /v2/home), and the v1 pages/ components are
not residue of that migration — they are the actual leaf implementations
mounted as route elements inside the v2 Shell. There is no parallel-model
trap here analogous to stripe.ts. ADR-0064
(operator auth model) governs a different surface — the cross-tenant
operator console (apps/operator-console) — and must not be cited for this
merchant-scoped admin app. No deciding ADR exists for the exceptions-queue
design or for a standalone audit-trail requirement; that absence is itself
a finding.
How it actually works¶
Routing. The admin SPA's only live route table is
App.tsx: / redirects unconditionally to
/v2/home, and every CS/exceptions surface mounts inside the Shell at
/v2/*. SubscriptionAdminDetail (subscription detail, US-21.6) is at
/v2/subscriptions/:id; the three standalone cs-tools pages — impersonate,
bulk-create, eligibility-audit — register at /v2/cs-tools/* with no
SideNav entry; the route table's own comment says why: "No role-gating
infrastructure in SideNav.tsx yet, so routes are registered but no sidenav
entry is added. Support staff navigate directly."
(App.tsx:128-137).
Force-charge / force-refund. CsActionsPanel.tsx, embedded in the
subscription detail page, posts to
POST /api/v1/admin/cs/subscriptions/:id/force-charge
and .../force-refund, both dispatched from
worker.ts:1289-1291. force-charge.ts
rejects a missing or blank reason with invalidFields (422) before
touching the database, then inserts a pending charge with
chain_position='subsequent' and a charge_origin='force_manual'
discriminator — the file's own header explains why: the chain_position
CHECK constraint only permits 'initial'/'subsequent', and D1 can't alter
a CHECK on an FK'd table, so the "this was forced" marker lives in a
separate column instead. The charge is scheduled at NOW, so the cron
picks it up on the next tick rather than charging synchronously — the UI's
"queued" toast and the audit event name
(subscription.force_charge_queued) both say this correctly.
Impersonation (US-22.3). POST /api/v1/admin/cs/impersonate
(impersonate.ts)
verifies the subscription belongs to the calling store, rejects
cancelled subscriptions, inserts a row into cs_tool_sessions
(id, store_hash, subscription_id, operator_id, expires_at,
used_at, ip_addr) with a 15-minute expiry, emits
subscription.cs_impersonation_started, and returns
{ session_id, expires_at, portal_url, read_only: true } where
portal_url points at <PORTAL_BASE_URL>/cs-view?session=<uuid>&sub=<id>.
This is where the traced mechanism diverges from the decision record —
see below.
Exceptions queue (US-21.3, US-12.1).
ExceptionQueueList.tsx
fetches via GET /api/exceptions on mount and again every
POLL_INTERVAL_MS = 30_000 ms (setInterval,
ExceptionQueueList.tsx:27-28,276-280) —
a failed poll is silent and keeps the last-good list rather than blanking
the page. Resolve
(resolve.ts)
requires a note and writes it to exceptions.resolved_by plus an
exception.resolved event; escalate
(escalate.ts)
takes no note and is a pure status flip — the file's header explains a
second D1 CHECK-on-FK'd-table workaround: 'escalated' is not a legal
exceptions.status value (migration 0018 locked the CHECK to
('open','resolved')), so escalation sets escalated_at instead and
listExceptions() derives the surfaced status='escalated' from
escalated_at IS NOT NULL. Both routes carry the same Phase-1 RBAC note as
resolve.ts's header: any authenticated merchant-staff user can act;
accountability is actor_user_id on the event, not a permission gate.
The chargeback exception type is produced by
db.ts (lines ~1918, 2048): a charge's
failure_code is classified as 'chargeback' when
code.includes('chargeback') is true.
Custom fields (US-22.8).
custom-fields.ts
loads every field definition in scope for the subscription's plan
(active and archived), rejects writes to archived keys with invalidFields,
and validates each touched field with validateFieldValue before persisting
— a real 422 path exists server-side. CsActionsPanel.tsx renders every
active field as a plain <Input> regardless of field_type; an enum field
carrying options[] is not rendered as a <Select>, and there is no
client-side check before submit — invalid values reach the server and come
back as a generic error string parsed from field_errors
(CsActionsPanel.tsx:146-169,716-725).
Allotment grants (US-22.7).
allotment-grants.ts
exposes GET .../allotments/:id (detail + debit history) and
POST .../allotments/:id/revoke (sets status='revoked', records
revoked_by/revoke_reason, emits allotment_grant.revoked). The file's
own BRD-AC comment describes four admin actions — "suspend / revoke /
manual-refresh / override-balance" — but only revoke is implemented; there
is no suspend, refresh, or balance-override route.
Manual create, B2B enrollment, and the price-lock fix.
handleAdminSubscriptionCreate
(subscriptions.ts:819) and
two B2B enrollment endpoints —
b2b-enrollment.ts
(POST /api/v1/admin/b2b-enrollment, CS-rep enrollment with
metadata.b2b = true) and
b2b-po-enrollment.ts
(POST /api/v1/admin/b2b-po-enrollment) — all call the shared
resolveLockedPrice helper.
Per that file's header: before commit 8ccaa0da (closes #1889), the admin
manual-create path didn't even select the columns needed to compute
locked_price_cents, so an admin-created subscription on a price-locked
plan silently floated with later plan edits; commits 77a439b1 (#1890) and
325a0351 (#1891) closed the same defect class at the B2B create sites and
made locked_price_cents type-required, so resolveLockedPrice is now the
one place this rule is defined across checkout, webhook, admin-create, and
both B2B paths.
Eligibility audit (US-26.10).
eligibility-audit.ts
is GET /api/admin/eligibility/audit, dispatched from
worker.ts:1829. It evaluates the same
rule chain as checkSubscriptionEligibility (mutex, custom_field,
qty_bounds, product_exclusion, category_allowlist, customer_group_scope,
geo_scope) but never writes state, and returns a per-rule trace with
outcome and reason. The file documents its own BRD drift: the BRD's
ui-states describe a POST with a JSON body; the shipped, routed contract is
this GET with query params — kept as the working contract per the project's
build-rule precedent.
No visual aid transcluded. Input-B records that no arch-derive
sequence diagram or focused ERD excerpt covers this domain — the events
table schema (audit-trail) has no sign_off field and only a
staleness_threshold_days marker, and docs/architecture/sequence-diagrams.md's
derives_from list contains no admin/CS-tools/exceptions source file. That
gap is stated here rather than papered over with an unrelated diagram.
Where intent and reality diverge¶
- Live-state contradiction (not a typed delta — a direct conflict with
the decision record). ADR-0050
§2–§3 specifies the impersonation token as a signed JWT carrying
role: 'cs_read_only', enforced GET-only by portal route middleware that checks that role. Tracing the actual code found none of that:impersonate.tsmints a barecrypto.randomUUID()stored server-side incs_tool_sessions(columnsid,subscription_id,operator_id,expires_at,used_at,ip_addr— not thecustomer_idcolumn ADR-0050 §1 specifies either), and returns aportal_urlpointing at<base>/cs-view?session=.... A repo-wide search forcs_read_only,cs_session_read_only, andcs-viewfound zero matches outsideimpersonate.tsitself and its own test — no portal route consumes that URL, and no role-based GET-only enforcement exists anywhere in the codebase. The one e2e step file that exercises this page says so directly in its own header comment: "The page issues NO GET on load, so the captured mint POST body is the route-wiring proof" — the mint call is stubbed in both CI run modes, and the read-only enforcement this decision's invariant depends on is never exercised, because it doesn't exist to exercise. Filed as a finding, not resolved here per the handoff-corpus contract's report-don't-resolve rule. - Verified-but-incomplete — the three cs-tools pages (impersonate,
bulk-create, eligibility-audit) are live-routed at
/v2/cs-tools/*and G4-verified but have noSideNaventry;App.tsx's own comment says role-gating infrastructure doesn't exist yet, so support staff reach them only by direct URL. Built and reachable, but not discoverable from navigation. - Verified-but-incomplete — the AllotmentGrant panel (US-22.7) is revoke-only: issue, suspend, manual-refresh, and override-balance actions named in the BRD AC and the route file's own comment are entirely absent.
- Verified-but-incomplete — custom-field editing (US-22.8) has no
client-side per-field validation, and fields carrying
options[](enum type) render as plain textInputs instead of selects. - Verified-but-incomplete — B2B admin enrollment (US-22.9) passes G4, but cadence, line items, ship-to-locations, and the payer/beneficiary/manager actor assignments required by AC1 are absent.
- Named-deferred — per-role CS access control (view-only vs resolving
staff) is explicitly deferred to Epic 29: BRD §US-1.6 defers the role
split, ADR-0050 §Explicitly-out-of-scope defers per-store CS ACLs to
Phase-3 RBAC, and
resolve.ts's header documents the Phase-1 model verbatim — codified in commit368139e3(synthesis #1333). - Named-deferred — instant impersonation-session revoke is not implemented (no token blocklist); a minted 15-minute session lives out its window (ADR-0050 §Explicitly-out-of-scope).
- Named-deferred — customer lookup from a BC App Extension (US-22.2) is
not built: coverage-matrix
g4_status: null, and its epic-22 ui-states block states "NOT YET BUILT — forward-looking contract." - Built-but-untrodden — chargeback rows in the exception queue
(US-12.5): the aggregator branch exists and is G4-scenario-passed
(
code.includes('chargeback')indb.ts), but it fires only when a charge'sfailure_codecontains "chargeback," and no Phase-1 webhook routes real chargebacks into that state — so the branch never renders a row in practice. The dispute workflow (accept/contest, evidence upload, auto-pause) is P2-deferred per Hive #923.
No comp/goodwill-credit action exists in code or BRD for this domain — the
nearest primitives are force-charge's amount_cents_override and partial
force-refund
(CsActionsPanel.tsx:297-324).
Traced as an absence, not typed as a delta, because no story or decision
establishes the intent.
How to operate & extend¶
- The invariant you must not break: no state-changing CS action
executes without an operator identity and a typed reason. Mutations only
through
/api/v1/admin/cs/*with an admin JWT;reasonmissing → 422. Given the live-state finding above, treat the impersonation session as not yet a hard read-only boundary until the portal-side enforcement is built and traced — don't rely on it as a security control in its current shipped form. - Adding a new CS override action: put the route under
/api/v1/admin/cs/*, requirereasonin the body (422 pattern inforce-charge.ts), and calllogEvent()withactor_user_idand a typed event name — that's the whole audit contract, there's no separate audit table to register with. - Adding a new exceptions action: mirror
resolve.tsorescalate.ts; if the new status value isn't in theexceptions.statusCHECK constraint, don't attempt a schema migration on the FK'd table — follow theescalated_at-as-discriminator pattern instead. - Adding a cs-tools page to navigation: the route already exists under
/v2/cs-tools/*; the missing piece is a role-gatedSideNaventry, which needs the Phase-3 RBAC work (Epic 29) to land first perApp.tsx's comment. - Closing the impersonation gap: implementing the portal's
/cs-viewroute and thecs_read_onlyGET-only enforcement ADR-0050 specifies is unstarted work, not a maintenance task — there is no partial implementation to extend. - Evidence pointers:
_coverage-matrix.json(Epic 22 US-22.1–22.9, Epic 21 US-21.3/21.6, Epic 12 US-12.1/12.5, Epic 13 US-13.1, US-26.10);epic-22-cs-tools.scenario.ts;force-charge.test.ts;admin-sub-create.test.ts;ExceptionQueueList.test.tsx; commits8ccaa0da/77a439b1/325a0351(price-lock closure); ADR-0050.
Confidence notes¶
- The impersonation live-state contradiction is the load-bearing finding
in this page. I traced
impersonate.ts,schema.sql'scs_tool_sessionsdefinition, and did a repo-wide grep forcs_read_only,cs_session_read_only, andcs-viewacross.ts,.tsx, and.sveltefiles (excludingnode_modules), and found matches only inimpersonate.tsitself, its test, the e2e step file, twostate-derivecatalog files, and the OpenAPI spec — none of which implement or exercise portal-side read-only enforcement. I did not find a Hive issue or ADR amendment documenting this as a known/accepted gap, so I'm reporting it as newly surfaced rather than citing it as already tracked — the team lead should confirm whether one exists that I missed. - Input-B's canonical-framing attestation states the enforcement mechanism as settled fact ("enforced GET-only at the portal route layer"). Per the contract's report-don't-resolve rule I did not edit or soften that Input-B text where I quoted it directly in Move 1; I added the contradiction as a separate, clearly-marked item in Move 3 and referenced it from the invariant guidance in Move 4 instead of silently reconciling the two.
cs_tool_sessionsschema column name. ADR-0050 §1 specifies acustomer_idcolumn ("the subscriber being impersonated"); the shippedschema.sqltable (line 704) hassubscription_id, notcustomer_id. I read this as consistent with the broader live-state finding rather than a separate, unrelated drift.- Two B2B enrollment endpoints. I found
b2b-enrollment.tsandb2b-po-enrollment.tsboth routed and both callingresolveLockedPrice; Input-B's descriptors and typed deltas only name US-22.9 generically. I did not find a decision record distinguishing the two beyond their route names and did not attempt to determine which one is canonical for US-22.9 — noting both rather than guessing. - Allotment-grant AC-vs-implementation gap. I read this directly from
the route file's own BRD-AC comment block
(
allotment-grants.tslines 9–16) rather than solely from Input-B's citation of the epic-22 derived view, so it's independently confirmed at the code layer. - I did not independently re-run the coverage-matrix derivation or the
epic-22 G4 scenario in this session; I'm relying on Input-B's
already-traced
g4_statusgrep results for the pass/null claims, and on my own direct reads for every code-level claim above.