@tokenoftrust/storefront-runner 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  2. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  3. package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
  4. package/apps/storefront/package.json +0 -1
  5. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
  6. package/apps/storefront/src/lib/activity/alerts.ts +428 -0
  7. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  8. package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
  9. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  10. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  11. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  12. package/apps/storefront/src/lib/activity/query.ts +403 -0
  13. package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
  14. package/apps/storefront/src/lib/activity/store.ts +122 -0
  15. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  16. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  17. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +4 -0
  18. package/apps/storefront/src/lib/auth/route.ts +44 -4
  19. package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
  20. package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
  21. package/apps/storefront/src/lib/env.ts +51 -0
  22. package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
  23. package/apps/storefront/src/middleware/index.ts +17 -0
  24. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  25. package/apps/storefront/src/pages/admin.astro +154 -6
  26. package/apps/storefront/src/pages/api/activity.ts +137 -0
  27. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  28. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  29. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  30. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  31. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  32. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  33. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  34. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  35. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  36. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  37. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  38. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  39. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  40. package/apps/storefront/src/pages/auth/login.astro +5 -4
  41. package/apps/storefront/src/pages/cockpit.astro +40 -3
  42. package/package.json +1 -1
  43. package/packages/public-runtime/src/activity/README.md +146 -0
  44. package/packages/public-runtime/src/activity/catalog.ts +501 -0
  45. package/packages/public-runtime/src/activity/event.ts +168 -0
  46. package/packages/public-runtime/src/activity/index.ts +16 -0
  47. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  48. package/packages/public-runtime/src/candidate-index.ts +13 -0
  49. package/packages/public-runtime/src/index.ts +6 -0
  50. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  51. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -29,6 +29,7 @@ import {
29
29
  StaffVendorAccessClientError,
30
30
  } from "@/lib/dashboard/staffVendorAccess";
31
31
  import { mintBrokerAssertion, BrokerAssertionError } from "@/lib/auth/brokerAssertion";
32
+ import { resolveUiActor, recordUiActivity } from "@/lib/activity/uiActor";
32
33
 
33
34
  export const prerender = false;
34
35
 
@@ -57,6 +58,11 @@ export async function POST(context: APIContext): Promise<Response> {
57
58
  typeof body.vendorAppDomain === "string" ? body.vendorAppDomain.trim() : "";
58
59
  if (!vendorAppDomain) return json({ error: "vendorAppDomain is required" }, 400);
59
60
 
61
+ // D11: staff-impersonation audit event. Resolve the actor AFTER the staff gate above,
62
+ // so the cohort is always `admin` (a ToT-staff session); the staff email is PII and is
63
+ // never stored (actor.id is its salted hash). Reused at each emit exit point below.
64
+ const activityActor = await resolveUiActor(context, {});
65
+
60
66
  let client;
61
67
  try {
62
68
  client = await createStaffVendorAccessClient();
@@ -84,6 +90,12 @@ export async function POST(context: APIContext): Promise<Response> {
84
90
  // capability, and writes the audit row. We admit ONLY on its ok.
85
91
  const result = await client.grantStaffAccess(brokerAssertion, vendorAppDomain);
86
92
  if (!result.ok) {
93
+ recordUiActivity(activityActor, {
94
+ action: "dashboard.vendor_entered",
95
+ outcome: { status: "refused", errorClass: result.reason ?? `http_${result.status}` },
96
+ scope: { tenantId: vendorAppDomain },
97
+ payload: { args: { resource: vendorAppDomain } },
98
+ });
87
99
  return json(
88
100
  { error: result.message, reason: result.reason, code: result.code },
89
101
  staffAccessHttpStatus(result.reason, result.status),
@@ -103,6 +115,12 @@ export async function POST(context: APIContext): Promise<Response> {
103
115
  // No SESSION KV / no cookie (e.g. plain `astro dev`) — the audit row is
104
116
  // written but we can't persist the marker. Fail closed so the viewer isn't
105
117
  // told they're admitted when the guard can't see it.
118
+ recordUiActivity(activityActor, {
119
+ action: "dashboard.vendor_entered",
120
+ outcome: { status: "failed", errorClass: "session_store_unavailable" },
121
+ scope: { tenantId: vendorAppDomain },
122
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
123
+ });
106
124
  return json({ error: "session store unavailable; cannot persist admission" }, 503);
107
125
  }
108
126
  const updated = await updateSession(
@@ -112,9 +130,21 @@ export async function POST(context: APIContext): Promise<Response> {
112
130
  nowSeconds,
113
131
  );
114
132
  if (!updated) {
133
+ recordUiActivity(activityActor, {
134
+ action: "dashboard.vendor_entered",
135
+ outcome: { status: "failed", errorClass: "session_expired" },
136
+ scope: { tenantId: vendorAppDomain },
137
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
138
+ });
115
139
  return json({ error: "session expired; sign in again" }, 401);
116
140
  }
117
141
 
142
+ recordUiActivity(activityActor, {
143
+ action: "dashboard.vendor_entered",
144
+ outcome: { status: "succeeded" },
145
+ scope: { tenantId: vendorAppDomain },
146
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
147
+ });
118
148
  return json({
119
149
  ok: true,
120
150
  appDomain: vendorAppDomain,
@@ -11,6 +11,7 @@
11
11
  * reach the browser. Inline style carries the per-request CSP nonce.
12
12
  */
13
13
  import { normalizeEmailHint } from "@/lib/privacy/emailHint";
14
+ import { safeNext } from "@/lib/auth/route";
14
15
  // Self-hosted web fonts (audit finding 6) — same-origin @fontsource, replacing
15
16
  // the render-blocking fonts.googleapis.com stylesheet. Covers Plus Jakarta Sans,
16
17
  // Inter, and JetBrains Mono used by this page's inline styles.
@@ -24,10 +25,10 @@ const sent = url.searchParams.get("sent") === "1";
24
25
  const email = (url.searchParams.get("email") ?? "").trim();
25
26
  const emailHint = normalizeEmailHint(url.searchParams.get("email_hint")) ?? "";
26
27
  const errorCode = url.searchParams.get("error");
27
- const next = (() => {
28
- const n = url.searchParams.get("next") ?? "/";
29
- return n.startsWith("/") && !n.startsWith("//") ? n : "/";
30
- })();
28
+ // Same open-redirect chokepoint the verify endpoint + login gate use — never a
29
+ // parallel weaker copy (this value is threaded into the sign-in form's hidden
30
+ // `next` and back to /api/auth/verify, which re-checks it with the same guard).
31
+ const next = safeNext(url.searchParams.get("next"));
31
32
 
32
33
  // RFC 9470 step-up: tot core told /api/auth/verify (or /api/auth/magic-exchange,
33
34
  // which redirects to /auth/magic instead) that it needs a fresher credential
@@ -27,6 +27,7 @@ import { readViewerSession } from "@/lib/auth/route";
27
27
  import { staticTenants } from "@/config/tenants";
28
28
  import { resolvePreviewTenant } from "@/config/previewTenantLookup";
29
29
  import { readTenantPreviewStatus, isStoreNotSetUp } from "@/lib/dev/previewStatus";
30
+ import { ensureCockpitVcBinding } from "@/lib/dev/vcBinding";
30
31
  import { resolveDevApiBase } from "@/lib/dev/apiBase";
31
32
  import { resolveCockpitStoreResource } from "@/lib/dev/cockpitStore";
32
33
  import { normalizeEmailHint, redactEmailForHint } from "@/lib/privacy/emailHint";
@@ -132,6 +133,30 @@ const storeNotSetUp =
132
133
  // hasStoreAccess) for anything that mints a command or starts the activity poll.
133
134
  const canSetupStore = hasStoreAccess && !noResolvableStore && !storeNotSetUp;
134
135
 
136
+ // vc-app-binding safety-net (08-18, §5): a BROWSER-FIRST developer reached this page
137
+ // via the storefront's own sign-in (a tot_session cookie) without ever crossing an
138
+ // MCP binding seam (rendezvous approve / native OAuth), so `appForCaller` doesn't
139
+ // resolve them and the `tot start` command we're about to hand them would 403 with
140
+ // "No version-control app is bound." Ensure the binding now — on-behalf of the
141
+ // verified session identity, via the app's private_key_jwt channel. Best-effort +
142
+ // fail-open (never blocks render), throttled to once per browser session so it stays
143
+ // off the hot render path; the authority-seam binding + backfill remain the real
144
+ // guarantee. Gated on canSetupStore (entitled + resolvable + provisioned), the exact
145
+ // condition under which we render a runnable command.
146
+ if (canSetupStore && session && !Astro.cookies.has("tot_vc_bound")) {
147
+ const vcBind = await ensureCockpitVcBinding(session.email);
148
+ if (vcBind.ok) {
149
+ // Throttle only on a real success — a fail-open miss retries on the next load.
150
+ Astro.cookies.set("tot_vc_bound", "1", {
151
+ path: "/",
152
+ httpOnly: true,
153
+ sameSite: "lax",
154
+ secure: true,
155
+ maxAge: 3600, // re-check hourly so a lost/revoked binding self-heals
156
+ });
157
+ }
158
+ }
159
+
135
160
  // Signed in, but either no developer capability on any store OR (staff bypass) no store
136
161
  // resolves for this viewer/host — an honest recovery state (not a 401): show what to
137
162
  // do, not the fastpath. NOTE: storeNotSetUp is deliberately NOT folded in here — it is
@@ -2651,6 +2676,17 @@ const hostedActivityScript = `
2651
2676
  var rzvTimer = null, rzvCode = null, rzvDecided = false;
2652
2677
  function rzvShow(el) { if (el) el.hidden = false; }
2653
2678
  function rzvHide(el) { if (el) el.hidden = true; }
2679
+ // Once the terminal has ATTACHED, the command has plainly been pasted and
2680
+ // run — hide the copy affordances so the approve prompt is the page's
2681
+ // single focus (DZ 2026-08-18). An EXPIRED sign-in restores the generate
2682
+ // button (a fresh mint is needed); the stale command's copy icon stays gone.
2683
+ function rzvHideCopyCtas() {
2684
+ if (btn) btn.hidden = true;
2685
+ if (copyIcon) copyIcon.hidden = true;
2686
+ }
2687
+ function rzvRestoreGenerate() {
2688
+ if (btn) btn.hidden = false;
2689
+ }
2654
2690
  function rzvStopPoll() { if (rzvTimer) { clearTimeout(rzvTimer); rzvTimer = null; } }
2655
2691
  function rzvFinish(msg) {
2656
2692
  rzvStopPoll(); rzvDecided = true;
@@ -2669,13 +2705,14 @@ const hostedActivityScript = `
2669
2705
  } catch (e) { rzvTimer = setTimeout(rzvPoll, 2800); return; }
2670
2706
  if (!r || !r.ok) { rzvTimer = setTimeout(rzvPoll, 3200); return; }
2671
2707
  if (b.state === "attached" && b.fingerprint) {
2708
+ rzvHideCopyCtas();
2672
2709
  rzvHide(rzvWait); rzvShow(rzvConfirm);
2673
2710
  if (rzvFp) { rzvFp.textContent = b.fingerprint; rzvFp.setAttribute("data-fp", b.fingerprint); }
2674
2711
  return; // stop polling — wait for the human decision
2675
2712
  }
2676
- if (b.state === "approved") { rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
2677
- if (b.state === "denied") { rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
2678
- if (b.state === "expired") { rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
2713
+ if (b.state === "approved") { rzvHideCopyCtas(); rzvFinish("Signed in ✓ — your terminal is good to go."); return; }
2714
+ if (b.state === "denied") { rzvHideCopyCtas(); rzvFinish("Rejected — that terminal wasn’t approved. You’re safe; generate a fresh command to try again."); return; }
2715
+ if (b.state === "expired") { rzvRestoreGenerate(); rzvFinish("This sign-in expired. Generate a fresh setup command above."); return; }
2679
2716
  rzvTimer = setTimeout(rzvPoll, 2500); // pending → keep watching
2680
2717
  }
2681
2718
  function rzvStart(code) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -0,0 +1,146 @@
1
+ # Operational activity telemetry — the actor×action contract (card D0)
2
+
3
+ The foundation the D1–D8 cards build on. Everything here is **pure types + pure
4
+ functions** in `@tot/public-runtime` (the node-free, dependency-free edge base),
5
+ so the storefront server, the Cloudflare Worker edge bundle, and the CLI's JS
6
+ mirror can all consume it. Nothing here does I/O or picks a store — the store
7
+ **decision** and cost model live in the repo-root `DECISIONS.md` §8; the
8
+ per-action store **routing** lives on each catalog entry (`sinks`).
9
+
10
+ Files:
11
+
12
+ | File | What |
13
+ |------|------|
14
+ | `event.ts` | The `ActivityEvent` envelope, `ACTIVITY_SCHEMA_VERSION`, `newEventId()`. |
15
+ | `catalog.ts` | The allowlisted `ACTION_CATALOG` registry + `ActionKey` + guards. |
16
+ | `redaction.ts` | Default-deny redaction contract + `createActivityEvent()` (the safe constructor). |
17
+
18
+ ## The envelope
19
+
20
+ One event says: **an ACTOR performed an ACTION, here is the OUTCOME**, scoped to
21
+ a tenant/change, from a source, at a time. The design invariant is a hard split
22
+ between two zones:
23
+
24
+ - **Core fields** (`v`, `id`, `at`, `actor`, `action`, `source`, `scope`,
25
+ `outcome`) — *always safe to store, never redacted*. They carry no free text
26
+ and no third-party PII **by construction**: `actor.id` is opaque, `action` is
27
+ an allowlisted catalog key, `outcome.errorClass` is a low-cardinality class
28
+ (never a raw message).
29
+ - **`payload`** (`args` + `rendered`) — the *extensible, always-redacted* zone.
30
+ Whatever a call site puts here is default-**denied**.
31
+
32
+ ```ts
33
+ import { createActivityEvent } from "@tot/public-runtime";
34
+
35
+ const { event, report } = createActivityEvent({
36
+ action: "cli.command.result", // must be a catalog key (compile-checked)
37
+ actor: { kind: "dev", id: hashedDevId },
38
+ source: "cli",
39
+ scope: { tenantId, traceId },
40
+ outcome:{ status: "succeeded", durationMs: 812 },
41
+ payload:{ args: { command: "ship", cliVersion: "1.4.0", exitCode: 0,
42
+ secretToken: "…" } }, // ← dropped: not allowlisted / key canary
43
+ });
44
+ // event.payload.args === { command: "ship", cliVersion: "1.4.0", exitCode: 0 }
45
+ ```
46
+
47
+ `createActivityEvent()` is the **only** sanctioned constructor: it fills
48
+ `v`/`id`/`at` and runs the raw payload through the redaction contract, so you
49
+ **cannot** produce an event whose payload skipped redaction.
50
+
51
+ ## The action catalog
52
+
53
+ `action` is never a free string — it is a key of `ACTION_CATALOG`. Each entry
54
+ declares `actors` (who may emit it), `source`, `sinks` (store routing), and the
55
+ default-deny `argsAllow` / `renderedAllow` payload allowlists. Adding a new
56
+ action = a deliberate edit to `catalog.ts` (+ the CLI mirror, below), never an
57
+ ad-hoc string at a call site. This is what keeps the D5 query surface and the
58
+ D6/D7 alert rules tractable.
59
+
60
+ ### The naming standard (two tiers)
61
+
62
+ Every key is dot-namespaced and its **first segment is a `<domain>`** — there are
63
+ **no un-namespaced keys**. The domain prefix is stable so dashboards and alert rules
64
+ can glob it (`change.*`, `preview.*`, …). Within that, the catalog uses **two shapes**,
65
+ chosen by whether the action is atomic or two-phase:
66
+
67
+ - **`<domain>.<verb>` (imperative)** — an **atomic** action that happens in one shot.
68
+ Examples: `change.ship`, `change.revert`, `change.go_live`, `grant.invite`,
69
+ `grant.revoke`, `store.connect`, `preview.override`.
70
+ - **`<domain>.<entity>.<verb|state>` (participle/state)** — a **two-phase / async**
71
+ action that **opens then closes** (an invoke/result pair, or a received→terminal span).
72
+ Examples: `cli.command.invoked` + `cli.command.result`, `preview.build.requested` +
73
+ `preview.build.completed`, `webhook.reconcile.received` + `reconcile.terminal`.
74
+
75
+ The **domain must be one of the closed set**: `cli`, `dev`, `preview`, `webhook`,
76
+ `reconcile`, `deploy`, `change`, `content`, `grant`, `store`, `app`, `agent`, `system`.
77
+ Adding a key with a new domain is a deliberate decision — widen this list here first.
78
+
79
+ **Two-phase pairing going forward:** a **new** two-phase action pairs `.invoked` /
80
+ `.result`. The pre-existing `.requested` / `.completed` (`preview.build.*`) and
81
+ `.received` / `.terminal` (`webhook.reconcile.received` + `reconcile.terminal`) pairs are
82
+ **grandfathered as-is — do not rename them** (`alerts.ts` hardcodes some of these literals,
83
+ so a rename would silently break the D7 detectors).
84
+
85
+ The initial catalog was reverse-engineered from the activity surfaces that **already
86
+ exist informally** in this repo (CLI dispatch + `~/.tot/activity.log`, the obstacle
87
+ beacon, the local→hosted dev bridge, preview/reconcile/webhook, deploy provenance, the
88
+ ShipLoop `CHANGE_STATES` + admin ship routes, app installs), so D1–D3 map onto it 1:1.
89
+
90
+ ## The redaction contract (the D8 attack surface)
91
+
92
+ Applied to `payload` for every event, in order:
93
+
94
+ 1. **Default-deny allowlist** — only the top-level keys named by the action's
95
+ `argsAllow`/`renderedAllow` are eligible. Everything else is **dropped**: not
96
+ stored, not logged, never "kept raw and redacted later".
97
+ 2. **Primitive-only values** — an allowlisted value must be string/number/bool.
98
+ Objects/arrays are dropped (nested structures are where secrets hide; we do
99
+ not walk them). Structured args flatten into named primitive keys at the call
100
+ site.
101
+ 3. **Canary scan** — allowlisted primitives are scanned for secret/PII **shapes**
102
+ (JWT, bearer, AWS/GitHub/Slack/OpenAI keys, private keys, email, card, phone,
103
+ high-entropy tokens) and secret-looking **key names** (`token`, `secret`,
104
+ `password`, `authorization`, …). A value hit → replaced with `«redacted»` and
105
+ reported (`report.canaryTripped`); a key-name hit → dropped outright.
106
+
107
+ The `RedactionReport` returned alongside the event carries **the fact of** each
108
+ drop/hit (lane, key, reason, canary label) — never the offending value. D4's
109
+ ingest logs it and emits `system.ingest.rejected` when a canary trips; D6/D7
110
+ alert on `canaryTripped`.
111
+
112
+ ## Consuming this from the downstream cards
113
+
114
+ - **D1 server `recordActivity()`** — import `createActivityEvent`, build the
115
+ event, fan out to the sinks named on the action's `spec.sinks` (start with
116
+ `logs`; supersede the interim `apps/storefront/src/lib/activity/recordActivity.ts`
117
+ slice, whose `candidate.reject` becomes `change.reject`).
118
+ - **D2 deploy provenance** — emit `deploy.version_observed`.
119
+ - **D3 CLI `emitActivity`** — the CLI is **pure JS and does not import TS
120
+ packages** (it publishes to npm standalone and mirrors TS contracts with
121
+ keep-in-step tests, e.g. `no-gitea-links.test.mjs`). So D3 ships a small JS
122
+ mirror of the `ActionKey` list + the safe-constructor shape, plus a
123
+ keep-in-step test that fails the build if the mirror drifts from
124
+ `catalog.ts`. **Do not** make the CLI depend on `@tot/public-runtime`.
125
+ - **D4 ingest + store** — validate with `isActionKey` + `isActorAllowed`, run
126
+ `redactPayload` (belt-and-suspenders even though emitters already redacted),
127
+ route by `spec.sinks`. See `DECISIONS.md` §8 for the D1 table (`activity_events`
128
+ in `STOREFRONT_APPS_DB`, alongside the existing `app_audit_log`) and the
129
+ Analytics Engine binding to provision.
130
+ - **D5 query/timeline UI** — reads the `timeline`-sink rows from D1.
131
+
132
+ ## Open questions deliberately left for downstream
133
+
134
+ - **Actor-id pseudonymization salt** (D1/D4). This module scrubs PII that leaks
135
+ into the *payload*, but `actor.id` in the core zone is the emitter's job to
136
+ pseudonymize. A shared salted non-reversible hash helper (salt as a Worker
137
+ secret) should land with D1/D4 — do **not** bake a weak unsalted hash into this
138
+ edge-shipped package.
139
+ - **Analytics Engine binding + D1 `activity_events` schema** (D4). Named in
140
+ `DECISIONS.md` §8; the actual `wrangler.toml` binding and the Drizzle migration
141
+ are D4's to write.
142
+ - **Retention/pruning job for the D1 timeline** (D4/D5). Arbitrary retention is
143
+ the reason the queryable cohort is in D1 not Analytics Engine; the pruning
144
+ cron is downstream.
145
+ - **Agent cohort payload** — `agent.action.*` keys are a placeholder spine with
146
+ minimal args until the agent-cohort card defines what an agent may record.