diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index 0f792c54a5..2891bdf8f7 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -376,3 +376,158 @@ posture, then decide which shape is wanted). hand-enumerated on the row-contract item and flagged un-pinned. - **No security-sensitive finding to withhold**: D16–D22 are admin-gated behaviors or disclosure-shape issues; nothing here discloses an unfixed privilege escalation. + +## 9. Full sweep 2026-08-30 — five-angle re-audit at a286411 (framework) / 1e14d70 (objectui) + +Ledger **221 → 260 items** (39 new, 13 revisions); `coverage.json` 31 kinds mapped, **0 +waived** (the first sweep to start from a zero-waiver state — nothing to re-audit there, +so the stale-claims audit ran against `blocked` refs and item texts instead, and found +five: §9d/§9e). Five parallel read-only hunters (console UI · spec enums · routes/runtime +· built-in apps · docs claims), nine per-area writers. Cross-angle hits drove priority: +`fieldGroups[].visibleWhen` was found by three angles independently, the marketplace +install-local surface by four. What follows is what is NOT a checklist item. + +### 9a. Product defects found while grounding (decide handling) + +Each is captured inside a checklist item as an expected-fail probe or knownGap, so a run +records actual behavior instead of ticking green. + +| # | defect | evidence | captured in | sensitivity | +|---|---|---|---|---| +| K1 | **KeyboardShortcutsDialog advertises dead accelerators.** Five listed keys have no handler anywhere (⌘/ focus-search, ⌘D dark-mode, N create, R refresh, ⌘E edit — repo-grep; only near-misses are page-scoped `r` in ApprovalsInbox and Ctrl+Shift+D debug); the sidebar row shows bare "B" while the binding requires ⌘/Ctrl+B; ⌘⇧O/⌘⇧S are advertised globally but their handlers are page-scoped to AiChatPage. A help surface teaching no-op keys. | objectui `app-shell/src/chrome/KeyboardShortcutsDialog.tsx:36-74` (only `?` handled at :88); `components/src/ui/sidebar.tsx:116-121` | platform-core.keyboard-shortcut-surface (expected-fail probes) | UX-integrity — safe to file | +| K2 | **System-hub "AI Approvals" card is not gated on the AI surface, and its inbox is error-blind.** The card renders unconditionally while every sibling AI entry point gates on `useAiSurface`; the page polls `/api/v1/ai/pending-actions` every 5 s forever, and renders a "No actions waiting" empty queue beside the error alert on the open edition's 501 (the remedy message itself does surface — that half is fine). | objectui `SystemHubPage.tsx:258-265`; `AiPendingActionsPage.tsx:45`; `AiPendingActionsInbox.tsx:286-297`; `useAiSurface.ts:24-28` | ai.console-ai-surface-gating (expected-fail clauses) | UX-integrity — safe to file | +| K3 | **`fieldGroups[].visibleWhen` is inert in the console one day after landing.** #13030 (2026-08-29) shipped the key with "declared = enforced on day one", but BOTH objectui fieldGroups adapters drop it, so the object-level section predicate never reaches the renderer; a separate fail-direction drift exists between the spec (fail-closed) and the view-section renderer (fail-open). | spec commit 53dc739 vs objectui `plugin-form/src/fieldGroups.ts:60-67`, `plugin-detail/src/synth/buildDefaultPageSchema.ts:622-635`; `object.zod.ts:1177` vs `TabbedForm.tsx:62` | records-forms.field-group-visible-when (console clause expected-fail at the exact adapter sites) | correctness — safe to file | +| K4 | **objectui external-datasource error UX drifted from the server.** The Setup federation UI's 503-detector matches the retired pre-#3843 string body while the server answers the sendError envelope (its own test pins the stale shape); and ValidationPanel's `DIFF_LABEL` covers 9 of 10 `SchemaDiffEntryKind`s — `'unreachable'` (emitted at `external-datasource-service.ts:789`) has no label. The #4115 class recurring until the next objectui spec-pin bump. | objectui `metadata-admin/external/api.ts:101-115` + `api.test.ts:65-70`; framework `external-datasource-routes.ts:383` | integration-system.external-schema-browser-ui (expected-fail + knownGaps) | correctness — safe to file | +| K5 | **Three raw-`getRawApp` route registrars remain unledgered** (D6/D22 class, and structurally invisible to the #7526 reverse-parity gate): the `/auth/me/permissions` + `/auth/me/localization` + `/me/apps` trio, `/api/v1/approvals/act`, and `/api/v1/webhooks/redeliver`. The trigger-api/metadata precedent (#11863/#11882) gives each such registrar a per-package ledger + conformance guard; these three never got one. (D22's `/automation/:name/clone` re-verified still unledgered at head.) | `plugin-hono-server/src/current-user-endpoints.ts:708,877,902`; `plugin-approvals/src/approvals-plugin.ts:349-361`; `plugin-webhooks/src/webhook-outbox-plugin.ts:386` | items now cover the routes' semantics (access-security.me-permissions-aggregation-parity, approvals.email-action-token-door, webhook-lifecycle rev 5); the ledger gap itself is this row | low — internal discipline | +| K6 | **Seed mode `replace` is declared≠implemented.** The spec sells it as "Delete ALL records, then insert" but the write arm is a bare insert whose comment says "caller should have cleared the table" — and no clearing caller exists anywhere. An ADR-0049 shape on the most dangerous member of the enum. | `packages/spec/src/data/seed.zod.ts` vs `packages/metadata-protocol/src/seed-loader.ts:2062-2065,2106` | platform-core.seed-mode-matrix (expected-posture clause — a run must not tick "deletion correctly scoped") | correctness — safe to file | + +Two design postures recorded inside items rather than as defect rows: scheduled-report +dispatch is wired **fail-closed** at head (`reports-plugin.ts:137` passes +`resolveOwnerContext: undefined` pending ADR-0073 M2, so every live scheduled dispatch +takes the refusal arm — dashboards.report-schedule-dispatch-delivery asserts exactly +that, with a flip-to-live tripwire); and the theme provider resolves `system` once per +evaluation with no matchMedia listener (platform-core.theme-mode-persistence asserts +resolve-at-load only). + +### 9b. Docs drift (PD#10 class — file as docs fixes, not checklist items) + +- **`content/docs/references/api/export.mdx:41,168,185`** advertises `jsonl`/`parquet` + formats and an async export-job vocabulary with **zero consumers** (see §9c); the live + door serves exactly csv/json/xlsx and silently coerces any other `?format=` to csv + (`rest-server.ts:7891-7892`) — a caller asking for the documented `parquet` gets a CSV + with a 200. +- **`capabilities/approvals.mdx:11`** counts the dead `queue` style among "eight + resolution styles" (#3508: resolves to **nobody**, designers must not offer it); same + section says department expansion "optionally" includes sub-departments — the spec + always includes all descendants. +- **`capabilities/integrations.mdx:22`** — "one-click record cloning": the server door is + real, but **no objectui surface calls `data.clone`** (confirmed independently by the + docs hunter and the records-forms writer). Say API/SDK, or ship the affordance. +- **`capabilities/views.mdx:23`** — "a default can be set per team": no per-team + default-view mechanism exists anywhere in the UI spec. +- **`capabilities/automation.mdx:19`** — notifications "(in-app, email, chat)": registered + channels are inbox/email/sms only (`messaging-service-plugin.ts:159,249,267`); a "chat" + notify dead-letters honestly, but the doc sells it as a delivery channel. +- **`capabilities/index.mdx:7,33`** — HotCRM "one-click install from the Marketplace": + the install door exists, but whether the public catalog lists HotCRM is unverifiable + in-repo — **maintainer check**, not asserted drift. + +### 9c. Declared-but-inert surfaces (ADR-0049 enforce-or-remove candidates — none got items) + +- **The whole async export-job surface** — `packages/spec/src/api/export.zod.ts` + (`ExportFormat` incl. jsonl/parquet, `ExportJobStatus`, job request/response): no + `.parse` site, no route, no producer; published by the export.mdx page above. +- **`ConcurrencyPolicySchema`** and its neighbor **`ScheduleStateSchema.status`** + (`automation/execution.zod.ts:368-387,411`): exported, referenced by nothing. +- **`driver-nosql.zod.ts` enum family** (consistency/read-write concerns/index/sharding): + `driver-mongodb` exists but no stock boot or fixture uses it — non-testable open-side. +- **`sys_notification_subscription` Setup grid** — declared-inert by its own docstring + (#9807: no `'subscribers'` audience member, nothing reads the rows). +- **objectui collaboration presence is unwired** (`PresenceAvatars` mounts but + `useRecordPresence` resolves `[]` — no `PresenceProvider` host anywhere; `LiveCursors` + and `CommentThread` have zero consumers); `OnboardingWalkthrough` is a deliberate null + stub. + +### 9d. Resolutions of earlier sections (append-never-rewrite rule) + +- **§7c's `datasource.checkOnBoot` design note is RESOLVED** — #13149 (2026-08-29) made + the flag enforced (`external-validation-plugin.ts:288-312` drops opted-out rows before + any verdict, with a named skip line); integration-system.external-schema-drift-gate + rev 2 now asserts the positive instead of the finding. +- **§7b's `admin-routes.ts:518` row is half-resolved**: the "no such consumer exists in + objectui" clause is now stale — objectui ships a live Setup → Datasources consumer of + the federation routes (`metadata-admin/external/api.ts`). The comment-accuracy question + it raised should be re-checked against that consumer before any cleanup edit. +- **§8a D16 is FIXED** (#12457): `setup-nav.contributions.ts:63` ships + `nav_packaged_automation`, pinned by `setup-packaged-automation-nav.test.ts`; + automation.setup-packaged-automation-board rev 2 inverted its expected-fail nav clause + to a positive assertion. +- **Two checklist items were filing-false-findings stale and are fixed in this PR**: + api-backend.route-ledger-live-parity (claimed /api/settings and /api/v1/datasources + are unledgered — both have ledgers + conformance tests now; rewritten to the real + **11-ledger** universe) and integration-system.datasource-admin-lifecycle (same + "unledgered" claim, plus its "always-available static catalog" wording predating the + #9391/#9593 uniform auth floor). + +### 9e. Blocked-item re-audit + +- **UNBLOCKED: identity-auth.oauth-app-consent-loop** — its "no stock oidcProvider flow" + claim conflated the platform-as-provider (what it tests, and which mounts by default: + `resolveOidcProviderEnabled` follows the MCP default TRUE) with an external IdP (what + `linked-accounts-social` genuinely needs — that one stays blocked). Every citation + verified before flipping; rev 2. +- **RE-PRICED: approvals.quorum-m-of-n** — the showcase now ships a real quorum flow + (`showcase_committee_quorum`, 2-of-3), so the remaining unblock is **one seed line** + (give Ada `finance` or `legal` → a live 2-of-2; a third distinct holder completes + 2-of-3), not the "showcase design call pending" the old ref claimed; rev 3. +- **Still blocked, re-verified at head**: approvals.sla-escalation (clock-control harness + — `timeoutHours` min 1, `runEscalations()` reads `this.clock`, no HTTP door injects); + access-security.no-active-org-session-semantics (unchanged; #13180/#13181 are new + adjacent instances of its fail-closed doctrine, noted on the item); + records-forms.import-job-undo-cancel (`import-console-undo.spec.ts` still self-skips + without `IMPORT_CONSOLE_LIVE=1`); identity-auth.linked-accounts-social (external + social/OIDC IdP genuinely required). + +### 9f. Checked and CLEAN (so the next sweep does not re-derive) + +- `data.mdx`'s "days until close date" formula claim is deliverable via + `daysBetween(today(), x)` — the build gate refuses only raw date arithmetic; NOT drift. +- MCP "on by default at /api/v1/mcp" matches `serve.ts:2315-2321`. +- Flow wait `eventType` members deliberately collapse to one suspend-with-correlation + branch (`wait-node.ts:286-289`) — no variants matrix owed; flow boundary events stay + waived-with-reasons in the node matrix. +- objectui's `FeedFilterMode` imports from spec (`RecordActivityTimeline.tsx:31`, + objectui#5969 two-directional pin) — an earlier hand-local-type drift concern is moot. +- The two previously item-unreferenced live objectui e2e specs + (`console-boot-indicator.spec.ts` #2628, `console-rendering.spec.ts`) are now cited on + platform-core.boot-health rev 5 — no orphan e2e specs remain. +- `service-knowledge` is composed by no shipped boot path — ai.mdx's "Knowledge answers" + rides the commercial assistant per that page's own closing note; nothing open-side to + test. +- cloud-connection's bind family and the marketplace browse proxy are control-plane + coupled; the offline arms are now covered + (platform-core.marketplace-install-local-lifecycle / marketplace-console-honesty), + browse legs blocked(environment/network) honestly. +- Stage machines (`state_machine` rule + meta legal-next-states), dashboards `compareTo`, + and backup-restore.mdx were each re-checked: covered / no runtime surface promised. + +### 9g. Fixtures worth adding (would un-block clauses recorded as knownGaps) + +Showcase one-liners: a `unique: true` field; a `fieldGroups[].visibleWhen` specimen; an +authored `deleteBehavior: 'restrict'` spelling; an `approvalStatusField` declaration on +one approval flow; the Ada `finance`/`legal` position line (§9e). Boot recipes: a +zero-user boot (`--no-seed-admin`, fresh DB) for the owner-bootstrap item; a +verification-enabled boot; an `OS_TENANCY_POSTURE=group` boot (also unblocks §8d's +operator-gate legs); a configured-`AuditPlugin` boot (the read-audit doc's own snippet). +Harnesses: an echoing upstream stub for connector auth kinds; a drifted scratch-DB recipe +for autoMigrate; a second verified non-grant user for the owner-email anchor's entitled +leg; scratch active approver flows + `sys_team` seeds for the resolution matrix. The +compiled install-local artifact already exists in-repo (`examples/app-crm` build). + +### 9h. Security note + +Nothing withheld from this PR. The three new auth-adjacent items +(access-security.platform-owner-email-anchor, approvals.email-action-token-door, +access-security.me-permissions-aggregation-parity) assert **shipped guards** already +public in their issues/ADRs; K1–K6 are UX/correctness/discipline findings; no unfixed +privilege escalation is disclosed anywhere in this sweep. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 4fde3b4606..6e1db64a5e 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1428,7 +1428,7 @@ "title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -1518,7 +1518,8 @@ "packages/plugins/plugin-sharing/src/share-link-service.ts (getPolicy publicSharing gate → 422 SHARING_NOT_ENABLED, resolveToken audience/password/expiry, #5190 recordStillExists fail-closed, list createdBy scoping)", "packages/runtime/src/route-ledger.ts (share-links rows incl. public resolve/messages)", "packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts", - "ADR-0047, ADR-0111 D8, #5190" + "ADR-0047, ADR-0111 D8, #5190", + "cross-ref access-security.share-link-landing-page — the /s/:token console rendering of this surface (UI half): resolve/password/audience/revoke SEMANTICS are scored HERE, what the page renders of them is scored THERE (one defect, one count)" ], "history": [ { @@ -1532,6 +1533,12 @@ "date": "2026-08-18", "change": "UNBLOCKED. showcase_client_brief is the stock publicSharing fixture and the seed carries both an eligible and an ineligible record, so mint / resolve / redaction are runnable on stock and the declared eligibility predicate is falsifiable too. `blocked` removed; the fixture is named in requires; the knownGap is rewritten as closed-by-fixture rather than deleted, with the warning that clause 1's negative control must stay pointed at an object that did NOT opt in. packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts pins the mint, the eligibility refusal and the redaction", "ref": "#9308" + }, + { + "revision": 3, + "date": "2026-08-30", + "change": "cross-referenced the new UI half (access-security.share-link-landing-page — the /s/:token console page): API semantics stay scored on this item, rendering on that one, so a leak found through the page's network trace is recorded here once and cross-referenced there, never double-counted", + "ref": "#sweep-2026-08-30" } ] }, @@ -2369,6 +2376,432 @@ "ref": "#12438" } ] + }, + { + "id": "access-security.platform-owner-email-anchor", + "title": "OS_PLATFORM_OWNER_EMAIL platform-admin anchor: only a VERIFIED stored-email match confers PLATFORM_ADMIN — unset, unverified match, non-match and malformed lists each confer nothing, fail closed", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin (seeded dev admin — grant-anchored via the unscoped admin_full_access row, the ADR-0068 D2 contrast; its address is seed-stamped verified per #11343)", + "fresh member M (the config-anchor subject; a fresh sign-up's sys_user row is UNVERIFIED, which is exactly the unverified-match fixture)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "boots with a controlled OS_PLATFORM_OWNER_EMAIL per variant — changing the variable means restarting the process: the value is read live per derivation but memoized per-process on the RAW string (platform-admin.ts:193-220), and there is deliberately no runtime mutation path", + "a manage_platform_settings-floor probe route: GET /api/v1/datasources answers 401/403 BEFORE any service resolution (packages/services/service-datasource/src/admin-routes.ts:87-90; DATASOURCE_ADMIN_CAPABILITY at :261), and admin_full_access carries that capability (packages/spec/src/identity/eval-user.zod.ts:116-124) — so the probe discriminates platform-admin standing cleanly" + ], + "knownGaps": [ + "the ENTITLED live leg needs a VERIFIED second account, and no stock HTTP path verifies one: email_verified is statically readonly at the user-context API (stripped — the write-path-guards class), a fresh sign-up reads unverified, and the seeded dev admin — whose address IS stamped verified at seed time (#11343, auth-plugin.ts:1752-1767) — also holds the unscoped admin_full_access grant, so its 200s can never attribute to the config anchor. Verify M's address via a verification-enabled boot with mail capture (identity-auth.email-verification-loop's fixture, itself a knownGap there) or a system-context stamp through the same isSystem doorway the seed itself uses; without either, clause 5's live leg scores blocked(fixture) and the entitled side rests on the unit pin" + ] + }, + "steps": [ + "boot showcase isolated with the variable UNSET; admin session; sign up fresh member M and record M's email; as M: GET /api/v1/datasources and GET /api/v1/auth/me/permissions (read systemPermissions) — the baseline refusals", + "restart with OS_PLATFORM_OWNER_EMAIL= (M's row is unverified); as M repeat both probes", + "restart with OS_PLATFORM_OWNER_EMAIL=; repeat M's probes", + "restart with a malformed list, e.g. OS_PLATFORM_OWNER_EMAIL=', not an address'; capture the [authz] refusal from the process log (it prints once, on the first derivation after the value is seen); as M repeat the probes — the WHOLE variable is refused, so even the well-formed entry confers nothing", + "entitled side (see knownGaps for the verified-member fixture): with M's row verified and the variable naming M, as M repeat the probes and read /api/v1/auth/me/permissions — expect the declared admin envelope; where the fixture is unavailable, run the unit pin and cite its output instead", + "with the variable unset again, drive one admin-standing derivation as the seeded admin (any authenticated request) and grep the process log for the once-per-process legacy-grant re-anchor pointer" + ], + "acceptance": [ + { + "clause": "unset = ZERO config-derived administrators, and the ADR-0068 D2 unscoped-grant anchor stays the only door: M's datasource probe is refused server-side (401/403 per identity, never 200) and M's systemPermissions lacks manage_platform_settings, while the seeded admin's identical probe answers 200 (both sides of the gate)", + "oracle": "api", + "verify": "M's GET /api/v1/datasources >=400 with the declared code; M's /auth/me/permissions systemPermissions array excludes manage_platform_settings; admin's same GET is 200", + "evidence": "both personas' traces" + }, + { + "clause": "an UNVERIFIED match confers nothing: with the variable naming M's address and M's stored row unverified, M's probes are refused exactly as at baseline — matchesConfiguredPlatformAdmin consults isEmailVerifiedUserRow and an absent/false column reads unverified (platform-admin.ts:243-252)", + "oracle": "api", + "verify": "M's datasource probe and systemPermissions read are byte-for-byte the clause-1 refusals despite the configured match", + "evidence": "the traces + the boot env captured in the run record" + }, + { + "clause": "a non-match confers nothing and changes nothing for anyone: with the variable naming an address no row holds, M's probes refuse as at baseline and the seeded admin still resolves through the grant anchor", + "oracle": "api", + "verify": "same probes as clause 1, identical outcomes", + "evidence": "the traces" + }, + { + "clause": "any malformed entry fails the WHOLE variable closed, loudly once: the process log carries the [authz] refusal naming the offending entry verbatim and stating the deployment now has ZERO config-derived platform administrators (parsePlatformAdminEmails, platform-admin.ts:141-153; sink at :218) — and the well-formed sibling entry on the same list confers nothing (never skip-and-continue)", + "oracle": "log", + "verify": "log line matches the refusal text and names the entry; M (the well-formed entry) still refused on the datasource probe", + "evidence": "the log excerpt + M's trace" + }, + { + "clause": "a VERIFIED stored-row match confers exactly the declared envelope: PLATFORM_ADMIN standing with the spec-declared ADMIN_FULL_ACCESS_CAPABILITIES content (systemPermissions including manage_platform_settings), hasPlatformAdminStanding agreeing, with NO grant row in sight — matching is case-insensitive over the caller's OWN stored sys_user.email, per entry of a comma-separated list", + "oracle": "test", + "verify": "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts — the acceptance-criterion describe ('yields PLATFORM_ADMIN with the DECLARED capability set', 'answers the id-shaped predicate too, with no grant row in sight', 'matches case-insensitively, and honours every declared entry of a list'); the LIVE leg (M verified + configured → datasource probe 200 and admin envelope in /auth/me/permissions) runs only with the knownGaps fixture, else scores blocked(fixture)", + "evidence": "test output (and, when the fixture exists, M's entitled traces)" + }, + { + "clause": "additive, never subtractive — the legacy anchor is honoured and loudly re-pointed: with the variable unset, standing resting on the unscoped admin_full_access grant alone still resolves PLATFORM_ADMIN, and the once-per-process [authz] pointer names the OS_PLATFORM_OWNER_EMAIL config line that re-anchors it (reportLegacyPlatformAdminGrant, platform-admin.ts:272-289)", + "oracle": "log", + "verify": "seeded admin's request succeeds AND the process log carries the legacy-grant pointer naming the variable; it appears once — grep the whole log, not the tail", + "evidence": "the log excerpt + the admin trace" + } + ], + "negative": [ + "no runtime write reaches the anchor: OS_PLATFORM_OWNER_EMAIL is declared a NON-TABLE derivation input (admin-standing-surface.ts:242-256) — revocation is a configuration change plus a process roll, by design. Any in-product surface found mutating who is a platform administrator is an authorization finding: record it under RUNNER rule 2's carve-out (item, clause, detail withheld pending maintainer), never with a reproduction", + "the derivation reads the caller's OWN STORED sys_user row, never the session-seeded grants.email — the ⭐ P1 pin of the leg. That property is asserted by the unit pin (the '⭐ … reads the STORED row, never the seeded email' describe); do not attempt to re-prove it against a live server", + "run every refused probe as the seeded admin too: a 200 proves each refusal keyed on the member's standing, not on a broken route (wrong-persona, both sides)", + "out of scope here: the WALLED-posture boot refusal when the variable is unset (#11184) is plugin-auth's separate door — do not score it on this item" + ], + "variants": [ + "unset (zero config-derived admins)", + "verified match (confers the declared envelope)", + "unverified match (nothing)", + "non-match (nothing)", + "comma-separated list — duplicates collapsed, trim+lowercase normalization (platform-admin.ts:129-159)", + "malformed entry (whole-variable refusal, loud once)" + ], + "traps": [ + "wrong-persona", + "auth-state-leak" + ], + "automated": { + "kind": "unit", + "ref": "packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts (the acceptance criterion, all four fail-closed arms, the ⭐ stored-row-not-seed pin, the legacy-grant pointer loudness) + packages/core/src/security/platform-admin.test.ts (parse/refusal/memo) + packages/core/src/security/admin-standing-surface.test.ts. STILL MANUAL: the live-HTTP arms over a real boot (clauses 1-4 and 6) — the pins run the derivation in-process; this item's live legs prove the same verdicts through the mounted server" + }, + "source": [ + "packages/core/src/security/resolve-authz-context.ts:619-667 (§6b-config — the config anchor inside the ONE derivation site; additive, never subtractive) + :696-760 (hasPlatformAdminStanding, the id-shaped projection)", + "packages/core/src/security/platform-admin.ts (parsePlatformAdminEmails whole-variable refusal :129-159, loud-once resolve :208-220, matchesConfiguredPlatformAdmin verified-only :243-252, reportLegacyPlatformAdminGrant :272-289)", + "packages/core/src/security/admin-standing-surface.ts:242-256 (the env declared a non-table derivation input — no break-glass write can reach it)", + "packages/types/src/env.ts:172,200 (PLATFORM_OWNER_EMAIL_ENV / resolvePlatformOwnerEmail)", + "packages/plugins/plugin-auth/src/auth-plugin.ts:1752-1767 (#11343 — the dev seed stamps its admin's address verified)", + "packages/services/service-datasource/src/admin-routes.ts:87-90,261 (the manage_platform_settings-floor probe)", + "#11663 L2 (the design and ruled bundle), #13146 (landed 2026-08-29), ADR-0068 D2 (the stored-grant anchor beside it)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the #11663 L2 deployment-config platform-admin anchor landed (#13146, 2026-08-29) with no checklist coverage. Env-conferred standing is a security gate and needs both sides asserted live: confer on a VERIFIED stored-row match only; nothing on unset / unverified / non-match / malformed (whole-variable fail-closed, loud once); plus the legacy-grant re-anchor pointer. Written as guard assertion per the shipped-guard posture; the entitled live leg is fixture-gapped (verified second account) and rests on the unit pin meanwhile", + "ref": "#13146" + } + ] + }, + { + "id": "access-security.me-permissions-aggregation-parity", + "title": "The /auth/me/permissions aggregation (and its /auth/me/localization + /me/apps siblings) mirrors server-side enforcement in both directions — and the trio's anonymous 200 is the deliberate exception to the 401 floor", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "contributor member C (showcase_contributor — carries the FLS grant on showcase_project budget figures, the field-parity fixture)", + "plain member P (member_default only — the withheld-verb contrast)", + "admin (the entitled /me/apps contrast)", + "anonymous (the designed-200 exception)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_contributor: allowEdit on showcase_project plus FLS budget/spent/budget_remaining readable:true/editable:false (permission-sets.ts) — the same fixture access-security.fls-mask-and-strip drives; this item asserts the AGGREGATION agrees with that enforcement, not the enforcement itself", + "member_default WITHOUT allowEdit on showcase_project — compute the ADR-0090 D5 baseline union per access-security.crud-permission-matrix's knownGaps before picking the withheld verb, or a baseline-granted verb will read as an aggregation violation", + "an app whose requiredPermissions a plain member lacks (the setup built-in requires setup capabilities admin_full_access carries) — the /me/apps contrast pair" + ], + "knownGaps": [ + "the SecurityPlugin-absent fail-open branch (current-user-endpoints.ts:740-754 empty-but-authenticated body; /me/apps failOpen returning every app) has no showcase fixture — a stack without SecurityPlugin is a different boot. Declared boundary; do not score it here" + ] + }, + "steps": [ + "boot showcase isolated; provision C (grant showcase_contributor via sys_user_permission_set), P (baseline only), admin; use a FRESH http client per persona (cache-staleness)", + "as C: GET /api/v1/auth/me/permissions — record objects.showcase_project, fields['showcase_project.budget'], permissionSets, systemPermissions", + "verb parity, both directions: as C PATCH a showcase_project name (the map says allowEdit) — 2xx; as P read P's own map (allowEdit absent/false on showcase_project) then issue the identical PATCH — 403 PERMISSION_DENIED", + "field parity: C's map says budget editable:false — C's budget PATCH is refused/stripped with the stored value unchanged (the fls-mask-and-strip oracle; cross-check only, do not re-score that item here)", + "as P then admin: GET /api/v1/me/apps — P's list excludes the capability-gated app and includes showcase_app; admin's includes it; for every returned app verify requiredPermissions ⊆ the caller's merged systemPermissions", + "as any member: GET /api/v1/auth/me/localization — the resolved currency/locale/timezone", + "anonymous (no Authorization header, fresh client): GET all three endpoints and capture status + body", + "self-scoping probe: as P, GET /api/v1/auth/me/permissions?userId= — the response must describe P, not C (no such parameter exists; prove it is ignored)" + ], + "acceptance": [ + { + "clause": "the objects map matches enforcement in BOTH directions: a verb the map grants succeeds live (C's project PATCH 2xx + persisted) and a verb the map withholds is refused live (P's identical PATCH 403 PERMISSION_DENIED) — the aggregation must neither under- nor over-claim", + "oracle": "api", + "verify": "compare each persona's /auth/me/permissions objects.showcase_project against their live PATCH outcome; any disagreement is a FAIL against the endpoint", + "evidence": "both maps + both PATCH traces" + }, + { + "clause": "the fields map matches FLS enforcement: C's fields['showcase_project.budget'] reads {readable:true, editable:false} and the live budget write is refused/stripped with the value unchanged, while the value IS served on C's reads (readable held)", + "oracle": "api", + "verify": "field-map entry vs the live write refusal + a read carrying the value; the most-permissive merge across C's sets is the expectation (current-user-endpoints.ts:764-793)", + "evidence": "the map + write trace + read" + }, + { + "clause": "/me/apps equals its declared filter: every returned app satisfies requiredPermissions ⊆ the caller's merged systemPermissions and tabPermissions !== 'hidden'; the capability-gated app appears for admin and not for P (both sides)", + "oracle": "api", + "verify": "per-app check of requiredPermissions against the same caller's /auth/me/permissions systemPermissions; P-vs-admin diff shows exactly the gated app(s) (current-user-endpoints.ts:963-968)", + "evidence": "both /me/apps bodies + the systemPermissions arrays" + }, + { + "clause": "the anonymous trio is pinned BY DESIGN, each with its own shape: /auth/me/permissions and /auth/me/localization answer 200 {authenticated:false}; /me/apps answers 200 {apps:[]} — the deliberate exception to access-security.anonymous-deny-surfaces' 401 floor (the frontend distinguishes anon from error; core auth-gate.ts:63 allow-lists /me/apps + /me/localization for gated users). A future run finding 401 here is a REGRESSION of the design, not a fix — never file 'missing auth' against these three", + "oracle": "api", + "verify": "the three anonymous traces match those exact statuses and bodies (current-user-endpoints.ts:710-712, :879-881, :904)", + "evidence": "the three traces" + }, + { + "clause": "the trio is self-scoped: the response describes the CALLER only — userId in the body equals the session's user, and a ?userId= query is ignored, so no caller can read another user's aggregation", + "oracle": "api", + "verify": "P's ?userId= probe returns P's own userId and P's own maps", + "evidence": "the probe trace" + }, + { + "clause": "localization rides the ExecutionContext without a setup gate: an ordinary member's /auth/me/localization answers 200 with currency/locale/timezone keys (nulls legal) — the SETTINGS surface is setup-gated, the resolved defaults deliberately are not", + "oracle": "api", + "verify": "member trace carries authenticated:true plus the three keys", + "evidence": "the trace" + } + ], + "negative": [ + "an over-claiming map is the dangerous direction and a FAIL against the endpoint: the frontend consumes this to render affordances, so a map granting what the server refuses turns every such surface into a dead-click generator — but the server refusal is still the authority (ADR-0124 D1); record the disagreement against THIS item, not as an enforcement hole", + "the trio is raw-mounted on the hono app and appears in NO route ledger (grepped absent from rest-route-ledger.ts, runtime route-ledger.ts and auth-route-ledger.ts) — this item is their only live-HTTP observation, so a 404 on any of the three is a mount regression no ledger conformance gate will catch; record it loudly", + "do not score the FLS enforcement itself here — access-security.fls-mask-and-strip owns it; this item owes only the parity cross-check (one defect, one count)" + ], + "variants": [ + "GET /api/v1/auth/me/permissions", + "GET /api/v1/auth/me/localization", + "GET /api/v1/me/apps" + ], + "traps": [ + "wrong-persona", + "auth-state-leak", + "cache-staleness" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts (the /me/apps half: member sees showcase, requiredPermissions gates, anonymous [], tabPermissions hidden drop and more-visible grant wins) + plugin-hono-server unit suites hono-current-user-endpoints.test.ts / current-user-endpoints-additive-baseline.test.ts / current-user-endpoints-position-grants.test.ts / current-user-endpoints-delegated-resolution.test.ts. STILL MANUAL: the live parity cross-check of the returned maps against actual enforcement responses (clauses 1-2) and the self-scoping probe" + }, + "source": [ + "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts:708 (/auth/me/permissions aggregation + most-permissive merge :764-809), :877 (/auth/me/localization), :902 (/me/apps requiredPermissions/tabPermissions filter :963-968), :57 (the /api/v1 prefix)", + "packages/core/src/security/auth-gate.ts:63 (ALLOW_SUFFIXES — /me/apps + /me/localization reachable to gated users)", + "#7616 (delegated permission-set resolution — the enforcement path's own answer), #2752 (/me/apps registry sourcing), #3391 (effective apiOperations annotation), #4093 (guarded degraded branch), ADR-0090 D5 (additive baseline)", + "cross-ref access-security.anonymous-deny-surfaces — the 401 floor this trio is the declared exception to", + "cross-ref access-security.fls-mask-and-strip — owns the FLS enforcement this item's clause 2 cross-checks" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the raw-mounted current-user trio (/auth/me/permissions, /auth/me/localization, /me/apps) is the console's whole permission layer and appeared in no ledger and no checklist item: aggregation-vs-enforcement parity was untested in either direction, and the deliberate anonymous-200 design (distinct bodies per endpoint: authenticated:false for two, apps:[] for the third — corrected from the sweep register, which implied one shape for all three) was unpinned and at risk of being 'fixed' into a 401", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "access-security.record-view-read-audit", + "title": "Opt-in record-view auditing writes `read` rows for exactly the opted-in objects' detail reads, batched off the request path — stock boot audits nothing, excluded names refuse loudly, values never copied", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "member M (the viewer whose detail reads land rows — a USER-context principal; system-context reads are the declared no-row boundary)", + "admin (reads the ledger)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "stock showcase boot for the honest-default half (grepped: nothing under examples/app-showcase constructs an AuditPlugin or names readAudit — the CLI's auto-registered instance passes NO options and audits no views)", + "read the rows through GET /api/v1/data/sys_audit_log — NEVER by opening a SQLite file directly: sys_audit_log's ADR-0057 lifecycle class routes it to the dedicated telemetry datasource whenever one is registered (os dev provisions one by default as a sibling file), and the data API resolves the right one; the write-failure text at read-audit.ts:473-484 documents exactly this trap" + ], + "knownGaps": [ + "the CONFIGURED half has no stock fixture and the provisioning is a BOOT-CONFIG change, not an API sequence — so no area recipe fits. Compose the docs' own snippet (content/docs/permissions/record-view-auditing.mdx, 'Under os serve'): a scratch app whose objectstack.config.ts plugins array carries new AuditPlugin({ readAudit: { objects: ['', 'sys_job'] } }) — sys_job included deliberately as the excluded-name probe. The 'Plugin superseded: com.objectstack.audit' boot line is the opt-in WORKING (last-one-wins over the CLI's option-less instance), not a misconfiguration. Without such a boot, clauses 2-5 score blocked(fixture); clause 1 (the stock negative) runs regardless", + "there is deliberately NO enable.auditReads object-metadata key, no stack-config key and no env flag (the docs state why: a declaration that can outlive the plugin reads as coverage while recording nothing) — a runner hunting for a config knob on the object will conclude the capability is missing (absence-inference); the opt-in lives ONLY where the plugin is constructed" + ] + }, + "steps": [ + "stock leg: boot showcase stock; as M open a record detail (GET /api/v1/data/showcase_contact/); wait past the 2s default flush window; as admin GET /api/v1/data/sys_audit_log with a $filter on action 'read' — expect zero rows; the boot log must carry NO 'record-view auditing installed' line", + "configured leg (see knownGaps): boot the scratch app with readAudit.objects naming object X plus the excluded sys_job; read the boot log for the install line and the exclusion warning", + "as M (user context): GET /api/v1/data/X/; wait past the flush window (or drive maxBatchSize reads); as admin read the ledger rows for action 'read'", + "as M: LIST /api/v1/data/X (a collection read) and GET a detail of an object NOT in the opt-in — then re-read the ledger", + "inspect the landed row's fields: action, user_id, object_name, record_id, old_value, new_value, created_at (compare created_at against the wall clock of the read, not of the flush)" + ], + "acceptance": [ + { + "clause": "stock boot audits nothing — the honest default: os serve/dev auto-registers AuditPlugin with NO options, so a stock boot writes zero action:'read' rows however many records are opened, and logs no install line (a deployment that opts nothing in pays nothing)", + "oracle": "api", + "verify": "post-read ledger query for action 'read' returns 0 rows on the stock boot; boot log lacks 'record-view auditing installed'", + "evidence": "the filtered query + the log grep" + }, + { + "clause": "the opt-in is a CLOSED per-object list: the configured boot's install line names exactly the surviving set ('AuditPlugin: record-view auditing installed on N object(s) — …', audit-plugin.ts:225-229), a detail read of an opted-in object lands one row (after flush), and reads of any object OFF the list land none", + "oracle": "api", + "verify": "install line lists X and not sys_job; M's GET /data/X/ produces exactly one action:'read' row; the un-opted object's detail read produces zero", + "evidence": "log line + the ledger rows before/after each read" + }, + { + "clause": "the row records WHO/WHICH/WHEN and nothing else: action 'read', user_id = M, object_name/record_id correct, old_value AND new_value both null (the afterFind hook sees PRE-MASK plaintext, so copying values would mint a plaintext copy of exactly what FLS withholds — read-audit.ts:55-75,516-521), and created_at is the VIEW instant, not the flush instant (:503-512)", + "oracle": "api", + "verify": "field-by-field read of the landed row; created_at within the read's wall-clock window, not the flush's", + "evidence": "the row + the recorded clocks" + }, + { + "clause": "the detail-read discriminator holds: a LIST read of the audited object lands no row, and only a findOne whose predicate pins the primary key in its AND-closure qualifies ($or/$not anywhere on the path refuse the proof — extractDetailReadId, read-audit.ts:333-375)", + "oracle": "api", + "verify": "after the list read the ledger count for action 'read' is unchanged; the by-id GET (which reaches the engine as findOne({where:{id}})) is what landed the row", + "evidence": "ledger counts around each read" + }, + { + "clause": "an exclusion-list name in the opt-in is refused LOUDLY at registration, never silently accepted: sys_job in readAudit.objects draws the warning naming the object ('is on the audit exclusion list … will NOT have its record views recorded', read-audit.ts:431-438) and the install line's set excludes it — configuration must not claim coverage the writer does not provide", + "oracle": "log", + "verify": "boot log carries the named warning AND the install line omits sys_job", + "evidence": "both log excerpts" + }, + { + "clause": "system-context reads produce NO row — the declared boundary: a read carrying session.isSystem (formula recompute, roll-up, any api.sudo() path — sudo keeps the caller's userId, so the flag is the ONLY discriminator) is the platform reading for its own bookkeeping, not a person opening a record (read-audit.ts:551-563)", + "oracle": "test", + "verify": "packages/plugins/plugin-audit/src/read-audit.test.ts pins the isSystem skip and the no-principal skip; do not tick this from an absence a live run did not deliberately drive", + "evidence": "test output" + } + ], + "negative": [ + "an audit write must never break the read: reads answer 200 even when the ledger write fails — the failure is reported once per process with the durability text (read-audit.ts:463-497) and rows are LOST, not retried. Observing that needs a broken ledger and is out of scope for a stock run; do not fabricate it", + "a read with no principal (no userId and no actor) lands no row — noise in the WHO ledger is refused by design (read-audit.ts:558-563)", + "cross-ref access-security.audit-log-browser — WRITE auditing, the browser UI, and the append-only guard belong there; this item owes only the read-writer's contract (one ledger, two items, no double-scoring). The append-only negative there covers action:'read' rows too: user-context POSTs to sys_audit_log are refused regardless of action" + ], + "variants": [ + "stock boot (nothing audited)", + "configured: opted-in detail read (row)", + "configured: list read (no row)", + "configured: un-opted object (no row)", + "excluded name in the opt-in (loud refusal at registration)", + "system-context read (no row — unit-pinned boundary)" + ], + "traps": [ + "eventual-consistency", + "wrong-persona", + "absence-inference" + ], + "automated": { + "kind": "unit", + "ref": "packages/plugins/plugin-audit/src/read-audit.test.ts (discriminator, batching/flush, isSystem + no-principal skips, exclusion filter) + audit-plugin.test.ts. STILL MANUAL: the stock-boot honest-default (clause 1) and the configured live loop through the mounted server (clauses 2-5, fixture-gapped per knownGaps)" + }, + "source": [ + "packages/plugins/plugin-audit/src/read-audit.ts (READ_AUDIT_ACTION :110, installReadAuditWriter opt-in + exclusion filter :415-443, extractDetailReadId :333-375, buildRow nulls + view instant :499-534, isSystem/no-principal skips :551-563, batcher :210-302)", + "packages/plugins/plugin-audit/src/audit-plugin.ts:210-229 (wiring readAudit options into the writer; the install log line)", + "content/docs/permissions/record-view-auditing.mdx ('Under os serve' — the option-less auto-instance audits no views; last-one-wins supersession is the opt-in path; no metadata key / stack key / env flag by design)", + "#8992 (the card and the 2026-08-16 maintainer ruling: Option A scoped MVP — detail views only, per-object closed opt-in, async batched writes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the #8992 record-view audit writer had no checklist coverage: the honest default (stock boot audits nothing), the closed per-object opt-in, the detail-read discriminator, the no-values row shape and the loud exclusion refusal were all unasserted. The configured half is fixture-gapped (a boot-config change per the docs' own snippet — recorded as knownGaps, not an area recipe, since it is not an API sequence); the stock negative runs on stock", + "ref": "#8992" + } + ] + }, + { + "id": "access-security.share-link-landing-page", + "title": "The /s/:token share-link landing page renders the resolved record minus redacted fields, gates on password, and refuses dead links with designed states — never a blank page or a raw error", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "minter (member holding showcase_client_liaison — mints the tokens via the API item's steps)", + "anonymous visitor (a FRESH browser context — see traps)", + "signed-in member (the audience:'signed_in' entitled side — signed in through the console FORM so both auth halves exist)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_client_brief — the stock publicSharing fixture (#9308 fixture 2), same as access-security.share-link-capability-tokens: the PUBLISHED brief is mint-eligible and redactFields declares ['internal_notes','deal_value']", + "tokens minted over the API as the liaison member per the API item's step shapes (plain, password, audience signed_in, short-expiry) — this item drives only the RENDERING; the mint/resolve/revoke API clauses stay on the API item" + ], + "knownGaps": [ + "automated.ref lives ENTIRELY in the objectui repo — from this checkout it is neither runnable nor pin-evidenced (the RUNNER standing fact): drive the item by hand in the browser, or run the pin in an objectui checkout and name the revision", + "the ai_conversations transcript branch (SharedRecordPage.tsx:108-132,190-234 — messages fetch + ChatbotEnhanced readOnly) is ai_conversations-only (Cloud/EE service-ai) — the same knownGap split the API item records for /:token/messages; not runnable on open-framework showcase", + "the page folds EVERY 401 into the password prompt (SharedRecordPage.tsx:70-75 branches on status alone, never on the body's NEEDS_PASSWORD vs SIGN_IN_REQUIRED code): a signed_in-audience link opened anonymously renders 'Password required' today, not a sign-in prompt. Clause 4's bar is therefore non-leak + a designed state; record the rendered state as measured, and revise this item if the console later distinguishes the two codes" + ] + }, + "steps": [ + "boot showcase with the console; as the liaison member mint the four tokens over the API (plain, password, audience:'signed_in', short-expiry); revoke a fifth after minting it", + "in a FRESH anonymous browser context open /s/: screenshot; then read the DOM — the page renders OUTSIDE the authenticated shell (App.tsx:210): 'Shared showcase_client_brief' header, the record body, and the hidden-fields notice naming the redaction set", + "capture the resolve network response for the same load and diff its record keys against the object's publicSharing.redactFields", + "open /s/: submit a wrong password, then the correct one; capture each round-trip", + "open /s/ and /s/; after the short-expiry link lapses, open it too; screenshot each terminal state", + "open /s/ anonymously (fresh context), then again as a member signed in through the console form (cookie + bearer both present)" + ], + "acceptance": [ + { + "clause": "the anonymous render works end to end MINUS the redaction set: record fields render, every field of publicSharing.redactFields ∪ the per-link redact_fields is absent from the DOM AND from the resolve network body (the strip is server-side; the page merely renders it), and the 'Some fields are hidden by the owner' notice names them — the notice depends on the redactFields→redactedFields envelope fold (normalizeResolvedShare), whose omission was exactly the objectstack#3983 regression", + "oracle": "network", + "verify": "resolve body's record omits the redaction set; after a screenshot confirms render, the DOM shows the record and the notice listing the redacted names (SharedRecordPage.tsx:236-255)", + "evidence": "the resolve body + screenshot + DOM read of the notice" + }, + { + "clause": "the password gate renders as designed and never leaks early: 401 → the 'Password required' prompt; wrong password → re-prompt with 'Wrong password.'; correct password → the record renders — and NO 401 response body carries the record", + "oracle": "network", + "verify": "the three round-trips (SharedRecordPage.tsx:70-75,143-176): two 401s with record-free bodies, then the 200 render; drive the input with native setter + events, not coordinate typing", + "evidence": "the three network traces + screenshots of prompt and render" + }, + { + "clause": "dead links refuse with designed, non-leaking states: revoked and expired render 'This link has expired or was revoked.' (410), a garbage token renders 'This link is invalid or no longer available.' (404) — never a blank page, a spinner that never settles, or a raw stack/JSON error, and no record data in any refusal body", + "oracle": "screenshot", + "verify": "each terminal state's screenshot shows the designed message (SharedRecordPage.tsx:76-85,178-186); the paired network bodies are record-free", + "evidence": "screenshots + the 404/410 bodies" + }, + { + "clause": "audience 'signed_in', both sides: the anonymous open never renders the record (the 401 body is record-free; the page shows a designed state — per knownGaps, today that state is the password prompt, record which renders), and the form-signed-in member's open renders it", + "oracle": "network", + "verify": "anonymous resolve 401 record-free + the rendered state noted; signed-in resolve 200 and the record renders (the resolve fetch rides the same-origin session cookie)", + "evidence": "both traces + both screenshots" + }, + { + "clause": "the page stands outside the authenticated shell: it renders for a visitor with NO session at all, with no console chrome/sidebar and no redirect to login", + "oracle": "screenshot", + "verify": "fresh-context load of the plain token shows the standalone layout (App.tsx:210 mounts /s/:token outside the shell routes)", + "evidence": "screenshot" + } + ], + "negative": [ + "any record data reaching a 401/404/410 network body is a SERVER failure first — record it against access-security.share-link-capability-tokens clause 5 and cross-reference from here (one defect, one count); the UI-only symptoms (blank page, raw error text, a rendered redacted field the body did not carry) are failures of THIS item", + "a DOM assertion made before a screenshot confirms render is not evidence — the loading state (SharedRecordPage.tsx:134-141) reads as an empty page to a premature dump" + ], + "variants": [ + "plain link (renders minus redactions)", + "password (prompt → wrong → correct)", + "revoked (410 message)", + "expired (410 message)", + "unknown token (404 message)", + "audience signed_in — anonymous (refused, designed state)", + "audience signed_in — signed-in (renders)", + "redacted-fields notice" + ], + "traps": [ + "hydration-race", + "stale-console-bundle", + "automation-input", + "auth-state-leak" + ], + "automated": { + "kind": "unit", + "ref": "objectui apps/console/src/pages/shared-record-shape.test.ts (the envelope fold + the redactFields→redactedFields rename — the #3983 pin); objectui-repo-only, see knownGaps. Everything rendered stays manual" + }, + "source": [ + "objectui apps/console/src/App.tsx:210 (the /s/:token route, mounted outside the authenticated shell)", + "objectui apps/console/src/pages/SharedRecordPage.tsx (status folds :70-91, password form :143-176, error states :178-186, generic record render + hidden-fields notice :236-255, ai_conversations branch :108-132,190-234)", + "objectui apps/console/src/pages/shared-record-shape.ts (normalizeResolvedShare — both envelopes, the #3983 rename)", + "cross-ref access-security.share-link-capability-tokens — the API half: mint/resolve/redaction/password/audience/revoke semantics and the #9308 showcase_client_brief fixture are asserted THERE; this item asserts only what the console renders of them", + "#9308 (the stock fixture), objectstack#3983 (the enveloped-branch rename regression the notice clause guards)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the UI half of the share-link surface: access-security.share-link-capability-tokens (surface:api) asserts the resolve contract but nothing asserted what /s/:token actually renders — the redaction notice (whose #3983 regression was precisely a render-only loss), the password prompt loop, the designed dead-link states, and the outside-the-shell mount. Cross-referenced both ways; the 401-fold boundary (password prompt rendered for a signed_in-audience 401) is recorded as a measured knownGap, not an expectation", + "ref": "#sweep-2026-08-30" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index d3538bc710..8e0c497154 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -576,6 +576,98 @@ "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: pins the open/cloud AI boundary as testable behavior (courtesy list + 501 remedy + discovery parity) instead of leaving /ai/** unswept; model-registry & conversation runtime behavior recorded as knownGaps rather than invented", "ref": "claude/platform-test-checklist-ocwugl" } ] + }, + { + "id": "ai.console-ai-surface-gating", + "title": "Console AI affordances gate on the access-filtered agent catalog: an agent-less boot hides FAB / chat dock / ⌘⇧I / top-bar link and redirects a stale /ai bookmark without flash — with the UNgated SystemHub 'AI Approvals' card pinned as an expected-fail probe", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["admin (form sign-in — the pending-actions poll sends the COOKIE half only, credentials:'include' with no bearer header, so an injected-token session reads as anonymous there)"], + "fixtures": { + "app": "any", + "requires": [ + "a stock OPEN-framework boot — no @objectstack/service-ai registered, so GET /api/v1/ai/agents answers the 200 empty-catalog courtesy (data.agents: []) for every authenticated principal (the same boot ai.open-edition-honest-degradation drives)", + "the objectui console (app-shell + apps/console) — the gating signal is client-side: useAiSurfaceEnabled polls the catalog and every entry point reads that ONE hook", + "a REAL form sign-in (not an injected localStorage token): the AI Approvals page's poll authenticates by cookie only (usePendingActions call() sends credentials:'include' and no Authorization header), so without the cookie half the poll answers 401 AUTH_REQUIRED instead of the 501 this item pins (RUNNER.md console-session fact)" + ], + "knownGaps": [ + "the ENTITLED side of the gate (catalog >= 1 agent ⇒ FAB/dock/link/route all appear) has NO open-framework fixture: service-ai is Cloud/EE and no stock agent-serving harness exists. The only lever is the VITE_AI_BASE_URL opt-in pointing the catalog fetch at an external AI server (useAiSurface.ts resolveAiApiBase), which needs a console REBUILD plus such a server — score those clauses blocked(fixture), never inferred from the hook's source", + "the PER-SEAT half of the signal (ai_seat holder sees agents, seat-less user gets an empty catalog on the SAME deployment — ADR-0068, the objectui#1992 revert) is equally cloud-only: on an open boot the catalog is empty for every principal, so per-seat differentiation is unobservable here", + "the /_console bundle is vendored and may be stale — verify affordance absence against current objectui app-shell or a fresh build before scoring (stale-console-bundle)" + ] + }, + "steps": [ + "boot the stock open framework with the console; sign in as admin THROUGH THE FORM (both auth halves needed — see fixtures); screenshot the settled shell", + "pin the signal first: in-page fetch GET /api/v1/ai/agents and capture the 200 envelope with data.agents [] — this is the one input every gated affordance reads (useAiSurfaceEnabled), so every absence below is scored against it", + "sweep the gated affordances on the settled shell: floating chatbot FAB, the right-docked chat rail, the AppHeader AI/assistant entry, the Home layout's AI CTAs — screenshot each region, THEN read the DOM to confirm absence", + "press ⌘⇧I (the chat-dock toggle) and confirm nothing mounts — ConsoleLayout only arms the listener when dockEnabled", + "navigate directly to /ai (the stale-bookmark path): capture that a loading fallback holds while the catalog resolves, then the redirect to home lands with the splash preserved — record whether any frame of chat UI flashed", + "as admin open the System hub (SystemHubPage): screenshot the admin card cluster and record whether the 'AI Approvals' card renders on this agent-less boot (expected at head: it DOES — the card is built unconditionally, SystemHubPage.tsx:258-265)", + "click the card through to system/ai-approvals; capture the network for ~15s: GET /api/v1/ai/pending-actions?status=pending firing every ~5s and answering 501 each time (poll never stops on error — usePendingActions clears nothing and re-arms)", + "capture what the page renders: the destructive alert's text (must carry the Cloud/EE remedy sentence from the 501 body), AND whether the 'No actions waiting / When the AI proposes a sensitive action it will appear here for review' empty state renders beneath it as if a live queue exists", + "capture the browser console for the whole session" + ], + "acceptance": [ + { + "clause": "the gating signal is the access-filtered agent catalog and nothing else: the console issues GET /api/v1/ai/agents (per navigation) and the open boot answers the 200 courtesy with data.agents [] — the empty catalog IS the 'no AI here' signal (never discovery's deployment-wide services.ai flag, which cannot express the per-user seat)", + "oracle": "network", + "verify": "the catalog request/response trace shows 200 with data.agents as an empty ARRAY (the relocated envelope, ai.ts #4053/#4058); the run scores the affordance sweep against THIS capture, not against source-reading", + "evidence": "the catalog request/response trace" + }, + { + "clause": "every gated affordance is ABSENT on the empty catalog: no FAB, no chat dock (⌘⇧I inert — the listener is not armed), no top-bar AI entry, no Home AI CTAs — hidden-during-load is the designed flash-free behavior, so a settled screenshot precedes every DOM read. The ENTITLED side (>= 1 agent ⇒ all of them appear) is blocked(fixture) on the open framework — record it blocked, never ticked from the hook's source", + "oracle": "screenshot", + "verify": "settled screenshots of shell chrome + Home show none of the affordances; post-screenshot DOM reads confirm; ⌘⇧I produces no mount (ConsoleLayout.tsx:117-127 gates the listener on dockEnabled = the same signal)", + "evidence": "the region screenshots + the ⌘⇧I non-event" + }, + { + "clause": "a stale /ai bookmark redirects WITHOUT flash: RequireAiSurface waits for the catalog to resolve (loading fallback, isLoading latch in useAiSurfaceEnabled) and then redirects home splash-preserved — never a broken/empty chat frame, never a redirect that flashes before the fetch starts", + "oracle": "screenshot", + "verify": "the /ai navigation shows loading → home with no intermediate chat frame (ConsoleShell.tsx RequireAiSurface + RedirectWithSplash, objectui#6507)", + "evidence": "the navigation capture / frame notes" + }, + { + "clause": "EXPECTED FAIL at head (defect K2, sweep 2026-08-30): the SystemHub 'AI Approvals' card follows the same gate as every other AI affordance — i.e. it is absent on an agent-less boot. At head it is NOT: SystemHubPage.tsx:258-265 builds the card unconditionally (no useAiSurfaceEnabled read, unlike FAB/dock/header/Home), so it renders and advertises a dead surface. A run that sees the card must score this clause FAIL with the screenshot — do not tick it green, and do not re-file the defect (the sweep's FOLLOW-UPS row owns it)", + "oracle": "dom", + "verify": "screenshot the hub first, then read the card grid: the designed contract is no 'AI Approvals' card on an empty catalog; observed-at-head is the ungated card", + "evidence": "the hub screenshot + card-grid DOM" + }, + { + "clause": "the landing page degrades with the REMEDY, not a fault: the poll's 501 body message (the single-sourced Cloud/EE sentence — see ai.open-edition-honest-degradation clause 2) surfaces verbatim in the page's destructive alert (usePendingActions call() throws body.error.message; AiPendingActionsInbox renders error.message)", + "oracle": "screenshot", + "verify": "the alert text string-equals the 501 body's message ('Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework'); diff alert text vs a captured 501 body", + "evidence": "the alert screenshot + one captured 501 response" + }, + { + "clause": "EXPECTED FAIL at head (defect K2, same row): honest degradation means no fake empty queue and no unbounded dead poll. At head, beneath the error alert the inbox ALSO renders the 'No actions waiting' empty state (rows.length === 0 branch is not error-aware, AiPendingActionsInbox.tsx:286-297) — reading as a live, empty approval queue on a deployment that has none — and the 5s poll re-arms forever against the dead endpoint (usePendingActions keeps polling on error). Score against the honest contract; record the observed fake-empty-queue + endless poll as the FAIL evidence, do not tick green", + "oracle": "network", + "verify": "the ~15s network capture shows the repeated 501s with no backoff/stop; the page screenshot shows the empty-queue panel rendered alongside the error alert", + "evidence": "the poll trace + the page screenshot" + } + ], + "negative": [ + "ticking any AI capability as PRESENT from the 200 empty-agents courtesy is a recording error — the empty catalog is the hide signal (same negative as ai.open-edition-honest-degradation)", + "scoring an affordance ABSENT from a DOM read taken before a settled screenshot is the hydration-race trap — the gated controls are hidden during load BY DESIGN, so a too-early read proves nothing", + "the two expected-fail clauses must not flip to PASS silently: if a run observes the card gated / the empty-queue suppressed, the defect was fixed — revise this item (drop the expected-fail wording, bump revision) rather than quietly ticking", + "an anonymous probe of /ai/pending-actions answering 501 instead of 401 would be a REGRESSION of the #7653 anonymous-deny ordering (auth gate precedes every capability answer) — file it, it is not this item's expected 501" + ], + "traps": ["hydration-race", "stale-console-bundle"], + "source": [ + "objectui packages/app-shell/src/hooks/useAiSurface.ts:9-40,71-88 (the ONE signal: access-filtered GET /ai/agents, per-seat rationale, isLoading latch for the route guard; do-NOT-simplify-to-discovery warning)", + "objectui packages/app-shell/src/layout/ConsoleLayout.tsx:77-127 (FAB + chat dock + ⌘⇧I all gated on showChatbot/dockEnabled)", + "objectui packages/app-shell/src/layout/AppHeader.tsx:149-152 (top-bar AI entry gated on the same hook) + console/home/HomeLayout.tsx:45-46 (Home CTAs)", + "objectui packages/app-shell/src/console/ConsoleShell.tsx:371-401 (RequireAiSurface — waits for resolve, splash-preserving redirect, objectui#6507)", + "objectui apps/console/src/pages/system/SystemHubPage.tsx:258-265 (the 'AI Approvals' card built UNconditionally — the K2 gap) + AppContent.tsx:167 (the system/ai-approvals route) + pages/system/AiPendingActionsPage.tsx (thin wrapper, 'Polled every 5 seconds')", + "objectui packages/plugin-chatbot/src/usePendingActions.ts:158-202,217-300 (cookie-only call(), error → error.message, pollInterval 5000 re-arming regardless of errors) + AiPendingActionsInbox.tsx:255-297 (destructive alert + the error-blind 'No actions waiting' empty state)", + "packages/runtime/src/domains/ai.ts:36-113 (#7653 anonymous-deny first; the /ai/agents empty-catalog courtesy #4058/#4053; every other /ai/* → capabilityUnavailable 501) + domains/unavailable.ts (single-sourced remedy sentence)", + "ai.open-edition-honest-degradation (the API half this item mirrors in the browser — 501 body/discovery parity is proven THERE, not re-proven here)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, cross-angle hit 1+4): the console-side AI gating had no coverage — the catalog-signal design (useAiSurfaceEnabled) hides FAB/dock/⌘⇧I/header/Home affordances and no-flash-redirects /ai on an agent-less boot, while SystemHubPage's 'AI Approvals' card is built ungated and its page polls the dead /ai/pending-actions endpoint every 5s (501) rendering a fake empty queue beside the error alert. Authored as a NEW browser sibling of ai.open-edition-honest-degradation (which keeps the API half) rather than extending it, so neither surface double-covers the other; the two K2 defect clauses are expected-fail probes, the defect row itself is the sweep's FOLLOW-UPS entry", "ref": "#sweep-2026-08-30" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 5c9d6526a2..6840dc14d2 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -642,10 +642,10 @@ }, { "id": "api-backend.route-ledger-live-parity", - "title": "Every ledgered route family — REST, dispatcher, auth, storage/i18n services — and the non-ledgered mounts (/api/settings, /api/v1/datasources) are actually mounted on the live server — no route that exists only in unit tests", + "title": "Every route family across all ELEVEN route ledgers — REST, dispatcher, auth, storage/i18n/settings/datasource services, metadata HMR, trigger-api, cloud-connection, console static — is actually mounted on the live server — no route that exists only in unit tests", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -654,7 +654,8 @@ "fixtures": { "app": "showcase", "requires": [ - "packages/rest/src/rest-route-ledger.ts — the audited route inventory (route-manager + direct-mount rows, full wire paths at /api/v1)" + "packages/rest/src/rest-route-ledger.ts — the audited route inventory (route-manager + direct-mount rows, full wire paths at /api/v1)", + "the other ten ledgers named in the steps — the ledger universe is ELEVEN files at head (grep the repo for '*route-ledger.ts' at run time rather than trusting this list to stay complete: a twelfth ledger landing is exactly the drift this item exists to catch)" ] }, "steps": [ @@ -662,7 +663,8 @@ "fire each sampled route as admin with a minimal-valid shape (GETs verbatim; parameterized routes filled with real seeded names, e.g. GET /api/v1/data/showcase_task, GET /api/v1/meta/object/showcase_task, GET /api/v1/security/explain)", "capture status + code per route", "read the OTHER ledgers and fire one representative route each: the dispatcher ledger (packages/runtime/src/route-ledger.ts) families share-links/keys/notifications/suggested-bindings/i18n/analytics (e.g. GET /api/v1/share-links, POST /api/v1/keys, GET /api/v1/notifications, GET /api/v1/security/suggested-bindings, GET /api/v1/i18n/locales, POST /api/v1/analytics/query), AUTH_ROUTE_LEDGER (GET /api/v1/auth/get-session), the storage + i18n service ledgers", - "fire the NON-LEDGERED mounts: GET /api/settings (note the /api/settings base — NOT /api/v1), GET /api/v1/datasources/drivers, GET /api/v1/datasources", + "fire the two service families that were UNLEDGERED when this item was first written and are ledgered at head — the run must NOT file the old unledgered finding: as admin GET /api/settings (settings-route-ledger.ts:66-75, #7526 — note the /api/settings base, NOT /api/v1) and GET /api/v1/datasources (datasource-route-ledger.ts:138-165, #7744). For the datasource family probe BOTH sides of the #9391/#9593 floor: anonymous GET /api/v1/datasources/drivers → 401 UNAUTHENTICATED (the 401 proves the route is mounted AND the floor holds — it is NOT the routing 404, and NOT the pre-#9391 'always-available static catalog'), then the same GET as admin (manage_platform_settings via admin_full_access) → 200 driver catalog; GET /api/v1/datasources as admin → 200 (or 503 naming the unwired service, which still proves the mount)", + "fire the RAW-APP-mounted families — these registrars mount via getRawApp() and are structurally invisible to IHttpServer.getMountedRoutes(), so the #7526 parity dogfood gate CANNOT observe them and this item's live probes are their only live-HTTP observation (each ledger header says so): metadata HMR GET /api/v1/dev/metadata-events (metadata-route-ledger.ts:104-122 — an SSE stream: read only the status line + headers with a short timeout, never wait for the stream to end; mounted ONLY under a literal NODE_ENV=development, which os dev sets — #12140/#5673) and POST /api/v1/dev/metadata-events; trigger-api POST /api/v1/automation/hooks/no_such_flow/bogus (trigger-api-route-ledger.ts — the handler answers a structured JSON refusal, distinguishable from the routing 404); cloud-connection GET /api/v1/runtime/config (anonymous 200 by design) and GET /api/v1/cloud-connection/status (cloud-connection-route-ledger.ts — 16 rows across four registrar families; sample only for MOUNT parity here, platform-core owns the marketplace lifecycle depth); console static GET /_console (console-route-ledger.ts — disposition static-asset)", "fire the dispatcher meta state route: GET /api/v1/meta/object/showcase_task/state/status?from=in_review, the same with ?from omitted, and GET /api/v1/meta/object/not_a_real_object/state/status as the 404 control", "fire one deliberately-unmounted path (GET /api/v1/definitely-not-a-route) as the 404 control", "compare GET /api/v1/discovery capability bits against the families that answered (search/export/transactionalBatch at minimum)" @@ -711,10 +713,22 @@ "evidence": "the per-ledger traces" }, { - "clause": "the NON-LEDGERED mounts answer too, and their absence from any route ledger is recorded as the finding: GET /api/settings (service-settings, mounted at /api/settings — NOT under /api/v1) answers non-404, and GET /api/v1/datasources/drivers (always-available static catalog) plus GET /api/v1/datasources (200, or 503 SERVICE_UNAVAILABLE when the admin service is unwired) answer non-404 — yet neither /api/settings nor the /api/v1/datasources admin CRUD appears in packages/rest/src/rest-route-ledger.ts (the tranche-3 route-ledger discipline gap, PENDING-GAPS §E)", + "clause": "the formerly-unledgered mounts are LEDGERED at head and answer live: GET /api/settings answers non-404 and its four routes are ledgered in packages/services/service-settings/src/settings-route-ledger.ts (#7526, guarded by the parity dogfood gate); the /api/v1/datasources admin family answers non-404 and its eleven routes are ledgered in packages/services/service-datasource/src/datasource-route-ledger.ts (#7744, guarded by its own conformance test). ⛔ A run that files the pre-revision-3 'unledgered mount / tranche-3 discipline gap' finding is filing a FALSE finding — that gap closed; the assertion is now parity WITH those ledgers", + "oracle": "api", + "verify": "the /api/settings and /api/v1/datasources traces are non-routing-404; the run record cites both ledger files instead of the retired unledgered-mount finding", + "evidence": "the two traces + the ledger-file citations" + }, + { + "clause": "the datasource-admin floor holds on BOTH sides (#9391/#9593): every sampled /api/v1/datasources route — the drivers catalog INCLUDED (its pre-#9391 posture was 'always-available static catalog'; that is history, kept in the ledger header on purpose) — answers 401 UNAUTHENTICATED anonymous and 403 PERMISSION_DENIED to an authed caller without manage_platform_settings, BEFORE any service is resolved (admin-routes.ts:85-99), while the admin's same request answers the real payload. An anonymous prober must read the 401 as 'mounted, floor holds', never as 'route missing' and never as a regression of the old open catalog", + "oracle": "api", + "verify": "three-persona trace of GET /api/v1/datasources/drivers (anonymous 401 / non-admin 403 / admin 200) plus one more family route anonymous → 401; compare the 401/403 bodies against the routing-404 control to prove they are handler answers", + "evidence": "the per-persona traces + the control diff" + }, + { + "clause": "the RAW-APP families answer live — metadata HMR (dev-posture), trigger-api hooks, cloud-connection/runtime-config, console static: each sampled route answers non-routing-404 (for the SSE stream, the status/headers of the initial response). These mounts go through getRawApp(), so 'routes an adapter mounts on its framework-native handle are outside this table by construction' (spec contracts/http-server.ts) — the #7526 parity gate is structurally blind to them and their per-package ledgers are source-scan/conformance guards, making this item's probes the only LIVE-HTTP observation of the family. Dev-posture caveat: /api/v1/dev/metadata-events mounts only under a literal NODE_ENV=development (#12140; unset means production per the #5673 ruling) — on the os dev boot it MUST answer; on a production-shaped boot its absence is the gate holding, recorded as posture, never as a missing mount", "oracle": "api", - "verify": "the /api/settings and /api/v1/datasources traces are non-routing-404; the run record notes both mounts are unledgered", - "evidence": "the two traces + the unledgered-mount finding" + "verify": "per-family status table (metadata-hmr GET+POST, trigger-api POST with a bogus flow → structured JSON refusal, GET /api/v1/runtime/config → anonymous 200, GET /api/v1/cloud-connection/status, GET /_console) vs the 404 control; the run env records NODE_ENV posture next to the HMR verdict", + "evidence": "the raw-app status table + control trace + posture note" }, { "clause": "the dispatcher meta state route is live AND correct: GET /api/v1/meta/object/showcase_task/state/status?from=in_review (dispatcher ledger meta.getLegalNextStates, ADR-0020 D3.3) answers non-404 and returns next == ['done','in_progress'] — exactly the declared task_status_flow transition set for that state; ?from omitted returns next:null (no from ⇒ no transition table), a field with no FSM returns next:null, and an unknown object → 404", @@ -756,22 +770,32 @@ "ledger:auth (AUTH_ROUTE_LEDGER)", "ledger:storage-service", "ledger:i18n-service", - "unledgered:/api/settings", - "unledgered:/api/v1/datasources" + "ledger:settings-service (settings-route-ledger.ts, #7526 — /api/settings base)", + "ledger:datasource-service (datasource-route-ledger.ts, #7744 — behind the #9391 manage_platform_settings floor)", + "ledger:metadata-hmr (metadata-route-ledger.ts — raw-app mount, dev-posture #12140)", + "ledger:trigger-api (trigger-api-route-ledger.ts, #11863 — raw-app mount)", + "ledger:cloud-connection (cloud-connection-route-ledger.ts, #11882 — raw-app mount, 16 rows)", + "ledger:console-static (console-route-ledger.ts, #11882 — raw-app mount, static-asset disposition)" ], "traps": [ - "dispatcher-vs-hono-route" + "dispatcher-vs-hono-route", + "absence-inference" ], "source": [ "packages/rest/src/rest-route-ledger.ts (variant source — the 19 REST families)", "packages/rest/src/rest-route-ledger.conformance.test.ts", "packages/runtime/src/route-ledger.ts (dispatcher ledger — share-links/keys/notifications/suggested-bindings/i18n/analytics families + the meta.getLegalNextStates state route)", - "packages/plugins/plugin-auth/src/auth-route-ledger.ts (AUTH_ROUTE_LEDGER — the enumerated better-auth table, #3656)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (AUTH_ROUTE_LEDGER — the enumerated better-auth table, #3656; raw-app mount)", "packages/services/service-storage/src/storage-route-ledger.ts + packages/services/service-i18n/src/i18n-route-ledger.ts (tranche-3 per-service ledgers, #3636)", - "packages/services/service-settings/src/settings-routes.ts (/api/settings — non-/api/v1 mount, UNLEDGERED)", - "packages/services/service-datasource/src/admin-routes.ts (/api/v1/datasources admin CRUD — UNLEDGERED, tranche-3 gap)", + "packages/services/service-settings/src/settings-route-ledger.ts (#7526 — the /api/settings family, ledgered; guarded by the parity dogfood gate, no per-package test by design)", + "packages/services/service-datasource/src/datasource-route-ledger.ts + datasource-route-ledger.conformance.test.ts (#7744 — the admin family, ledgered)", + "packages/services/service-datasource/src/admin-routes.ts:85-99 (the #9391/#9593 manage_platform_settings floor on all eleven routes, drivers catalog included)", + "packages/metadata/src/metadata-route-ledger.ts:104-122 (metadata HMR — raw-app mount, dev-only posture #12140/#5673)", + "packages/triggers/trigger-api/src/trigger-api-route-ledger.ts (#11863 — POST /api/v1/automation/hooks/:flowName/:hookId, raw-app mount)", + "packages/cloud-connection/src/cloud-connection-route-ledger.ts (#11882 — 16 rows / four registrar families, raw-app mounts unfindable by the parity gate by construction)", + "packages/cli/src/utils/console-route-ledger.ts (#11882 — the four static-asset rows)", + "packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts (#7526 — the parity gate whose getMountedRoutes() blind spot the raw-app clause covers)", "examples/app-showcase/src/data/objects/task.object.ts (task_status_flow transitions for the meta state-route clause)", - "docs/qa/platform-checklist/PENDING-GAPS.md §D/§E", "#3587", "#3361" ], @@ -787,6 +811,12 @@ "date": "2026-08-08", "change": "extended the sweep beyond the 19 REST families to the OTHER ledgers + non-ledgered mounts: dispatcher ledger (share-links/keys/notifications/suggested-bindings/i18n/analytics), AUTH_ROUTE_LEDGER, storage/i18n service ledgers, /api/settings, /api/v1/datasources; added a live-mount clause per ledger with a 404 control (the #3361 dispatcher-vs-hono class), and folded in the meta.getLegalNextStates state route (legal next states == declared task_status_flow set) since api-backend is its natural home", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 3, + "date": "2026-08-30", + "change": "de-staled against the head ledger universe, re-derived from source (find '*route-ledger.ts' — ELEVEN files). Three revision-2 claims had gone false and a run following them files false findings: (a) the 'unledgered:/api/settings' and 'unledgered:/api/v1/datasources' variants/clauses/sources — both families are ledgered at head (settings-route-ledger.ts #7526; datasource-route-ledger.ts + conformance test #7744) and the tranche-3 discipline-gap finding is retired; (b) step 5's 'always-available static catalog' for GET /datasources/drivers — since #9391/#9593 ALL eleven datasource-admin routes (catalog included) answer 401/403 without manage_platform_settings, so the step now drives all three personas and pins the 401-means-mounted reading; (c) the PENDING-GAPS.md source refs — that file was deleted after the 2026-08 sweep (FOLLOW-UPS.md records it). Extended the sweep to the five raw-app ledgers the revision-2 universe missed (metadata HMR with its #12140 dev posture, trigger-api #11863, cloud-connection #11882 with 16 rows, console static #11882 — each header states getMountedRoutes() cannot see them, making this item their only live-HTTP observation); added the corresponding variants and the absence-inference trap", + "ref": "#sweep-2026-08-30" } ] }, @@ -1556,6 +1586,340 @@ "ref": "#12438" } ] + }, + { + "id": "api-backend.aggregate-contract-matrix", + "title": "Server-side aggregation contract: every AggregationFunction × DateGranularity gives known answers — ruled empty-set values, ISO-Monday week buckets, retired array_agg/string_agg refuse with prescriptions, and aggregate stays secured (engine-only, never raw driver)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin (known-answer runs over the seeded baseline)", + "a restricted non-admin persona for the secured-aggregate leg (Mei Phone phone.demo@example.com or Ada Auditor auditor.demo@example.com, DEMO_PERSONA_PASSWORD 'showcase123' — same seeded personas api-backend.error-envelope-ledger names)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "seeded showcase_account rows (numeric annual_revenue, date signed_on, select status/sales_region) — the same known-answer dataset api-backend.query-contract-matrix uses; expected values are ALWAYS computed locally from an unfiltered baseline read, never hardcoded", + "a boolean field for the #11152 min/max-over-boolean leg (showcase_field_zoo carries checkbox-typed fields)" + ], + "knownGaps": [ + "Stock seed dates may not spread signed_on across enough DISTINCT buckets for every granularity to be answer-discriminating (e.g. all rows in one ISO week proves week-bucketing resolved, not that it bucketed correctly). Same discipline as api-backend.date-range-preset-matrix: record which granularities were answer-discriminating vs merely well-formed; seeding a few rows at known date offsets during the run is the honest close — record it if done.", + "Whether a given aggregate is answered by driver pushdown or by the in-memory path (packages/objectql/src/in-memory-aggregation.ts) depends on driver + timezone (tz≠UTC date buckets take the in-memory path per the #1982 note); the two paths must label identically (#3773/#3839 seam). Record which path answered when it is observable (e.g. via the timezone lever), and score the CONTRACT, which is path-independent." + ] + }, + "steps": [ + "read the two enums and pin the matrix: AggregationFunction (packages/spec/src/data/query.zod.ts:149 — 6 members: count/sum/avg/min/max/count_distinct, with retirement error messages wired for array_agg/string_agg at :86-96/:157-161) and DateGranularity (:174 — 5 members: day/week/month/quarter/year). Only ONE enumSource pin fits an item; AggregationFunction carries it — hand-count DateGranularity's 5 members at run time and record the count", + "boot showcase isolated; as admin read the unfiltered showcase_account baseline (generous $top) — the local ground truth every expected value below is computed from", + "function matrix: for each of the 6 functions POST /api/v1/data/showcase_account/query with { groupBy: ['status'], aggregations: [{ function, field: 'annual_revenue', alias: 'v' }] } (count also in its fieldless/'*' spelling), plus one UNgrouped run per function; compute each expected per-group value locally from the baseline and compare exactly — known answers, not smoke 200s. Add the #11152 leg: min/max over a boolean field answer the NUMBER it is worth (0/1, maintainer ruling 2026-08-28), never false/true", + "granularity matrix: record wall clock + timezone, then for each of the 5 granularities POST { groupBy: [{ field: 'signed_on', dateGranularity }], aggregations: [{ function: 'count', alias: 'n' }] }; reconcile the returned bucket KEYS and counts against locally computed ISO-8601 buckets (bucketDateValue's own shapes: 'YYYY', 'YYYY-Qn', 'YYYY-MM', 'YYYY-MM-DD', 'YYYY-Wnn' with weeks starting Monday / week 1 = first Thursday); rows with null/unparseable dates must land in ONE real-null-keyed bucket (#3839 — never a '(null)' sentinel string)", + "empty-set semantics: (a) a query whose where matches nothing, grouped and ungrouped; (b) a per-aggregation filter (#10576 — aggregations[].filter, SQL FILTER(WHERE …) semantics) that empties one measure while a sibling measure keeps the full bucket. Expected per the platform ruling (emptyGroupValueFor, packages/spec/src/data/aggregation-policy.ts:52): count/count_distinct/sum → 0, avg/min/max → null — a 0 flattened onto avg (or a null on count) is a FAIL either direction (objectui#3136)", + "negatives: aggregations [{ function: 'array_agg', field: 'name', alias: 'x' }] and the string_agg twin → 400 with the RETIREMENT prescriptions (each message ends with the 'os migrate meta --from 16' instruction; only those two spellings get it); an unknown function ('median') → 400 whose issue lists the six legal functions (zod's own enum error, deliberately NOT the retirement text — telling a typo'd author their value 'was removed' would misinform); aggregations naming a nonexistent field → the located protocol refusal (protocol.ts assertAggregationFieldsExist, :8883); aggregations as a non-array → the shape refusal with the worked example", + "secured-aggregate leg: run one grouped count as the restricted persona over an object where their visible row set provably differs from the admin's; compute their expectation from THEIR OWN filtered baseline. The rule under test is the one packages/runtime/src/action-execution.ts:328 states for the aggregate door: aggregate MUST run through the ObjectQL engine (RLS/tenant middleware + the FLS aggregate-input gate) — a raw driver.aggregate() evaluates over every row" + ], + "acceptance": [ + { + "clause": "every AggregationFunction returns exactly the locally-computed answer over the seeded rows, grouped and ungrouped, including count's fieldless/'*' spelling (count-all) vs its per-field spelling (non-null count) — value equality per group, not row-count smoke", + "oracle": "api", + "verify": "per-function diff table of returned values vs baseline-computed expectations; the count-'*' vs count-field distinction is asserted with a row whose aggregated field is null", + "evidence": "the per-function diff table" + }, + { + "clause": "every DateGranularity buckets to the ISO-8601 shapes with weeks starting MONDAY (week 1 contains the first Thursday), quarter as YYYY-Qn, and null/unparseable instants sharing one real-null bucket key (#3839) — bucket keys AND counts both reconcile against the locally computed windows from the recorded clock+zone", + "oracle": "api", + "verify": "per-granularity table: returned bucket keys/counts vs local computation; the week leg includes at least one date whose ISO week differs from its naive Sunday-start week", + "evidence": "the 5-row bucket table + the recorded clock/zone" + }, + { + "clause": "empty-set values are the RULED identities, on both the empty-where and the #10576 measure-scoped-filter paths: count/count_distinct/sum → 0 (measured facts), avg/min/max → null (undefined, never flattened to a zero that reads as a measurement) — matching emptyGroupValueFor, the contract in-memory-aggregation.ts:180-246 implements", + "oracle": "api", + "verify": "the six per-function empty-set values on both paths; a sibling unfiltered measure in the same request keeps its full-bucket value (the #10576 isolation half)", + "evidence": "both responses + the expected-value table" + }, + { + "clause": "retired functions refuse with their PRESCRIPTIONS and unknown functions with the enum listing: array_agg/string_agg answer 400 carrying their registered retirement messages (query.zod.ts:86-96 — no replacement exists; the message says to reshape and names 'os migrate meta --from 16'), while an invented function gets zod's own enum error listing the six legal members — the two error classes must not blur (the enum error map returns the retirement text ONLY for the two spellings that used to be legal)", + "oracle": "api", + "verify": "the three rejection bodies: two carry the retirement prescriptions, the third lists count/sum/avg/min/max/count_distinct; all are 400, never 500", + "evidence": "the three rejections" + }, + { + "clause": "aggregation is SECURED: the restricted persona's aggregate equals the expectation computed from THEIR visible rows only — an aggregate that matches the admin's over rows the persona cannot read is the raw-driver bypass (the exact hazard action-execution.ts:328 forbids: only the engine's middleware chain injects RLS/tenant scoping and the FLS aggregate-input gate)", + "oracle": "api", + "verify": "restricted persona's grouped count vs their own filtered baseline AND vs the admin's differing answer — both comparisons recorded", + "evidence": "both personas' responses + both baselines" + }, + { + "clause": "every variant below carries a recorded verdict — 6 functions and 5 granularities, none sampled away; granularities that were not answer-discriminating on the seeded data are recorded as such, never silently passed", + "oracle": "api", + "verify": "run record has one row per variant with the discriminating/well-formed-only flag on the granularity rows", + "evidence": "the 11-row run record" + } + ], + "negative": [ + "a refused/unknown aggregation degrading to RAW ROWS with a 200 is the catastrophic direction — the aggregate action door's own rule ('grouped/aggregated output must stay the only thing this action can ever return', action-execution.ts) exists because the engine's in-memory path degrades to raw rows when neither aggregations nor groupBy survive, and the FLS result masker does not cover the aggregate op", + "an aggregation over a nonexistent field answering 200 (a computed value over nothing) instead of the located refusal is the #4134 class applied to the aggregation axis", + "the two bucket paths (driver pushdown vs in-memory) labeling the same instant differently is the #3773 seam — record both labels if the timezone lever exposes the divergence; it is a FAIL even when each path is self-consistent" + ], + "variants": [ + "fn:count", + "fn:sum", + "fn:avg", + "fn:min", + "fn:max", + "fn:count_distinct", + "gran:day", + "gran:week (ISO — Monday start)", + "gran:month", + "gran:quarter", + "gran:year" + ], + "enumSource": { + "file": "packages/spec/src/data/query.zod.ts", + "export": "AggregationFunction", + "expect": 6 + }, + "traps": [ + "seed-data-thin", + "single-datapoint", + "timezone-boundary", + "wrong-persona" + ], + "source": [ + "packages/spec/src/data/query.zod.ts:86-165 (AggregationFunction + ARRAY_AGG_RETIRED/STRING_AGG_RETIRED error map), :174 (DateGranularity), GroupByNodeSchema (dateGranularity spelling)", + "packages/objectql/src/in-memory-aggregation.ts (:180-246 the six function arms incl. #11152 boolean-as-number and the #10576 per-aggregation filter; :300-347 bucketDateValue ISO shapes; the #3839 real-null bucket rule at the module head)", + "packages/spec/src/data/aggregation-policy.ts:52 (emptyGroupValueFor — the ruled empty-set identities; objectui#3136)", + "packages/runtime/src/action-execution.ts:328 (the aggregate door: engine-only, at-least-one-aggregation, the raw-rows degradation hazard)", + "packages/rest/src/rest-server.ts:7086 (POST /data/:object/query — the wire door that accepts groupBy/aggregations)", + "packages/metadata-protocol/src/protocol.ts:8883 (assertAggregationFieldsExist — the located unknown-field refusal)", + "#10576, #11152, #3839, #3773" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the aggregation vocabulary (6 functions × 5 granularities) had no matrix item: api-backend.query-contract-matrix covers the filter/select/sort axes of the same door and stops short of groupBy/aggregations. Register claims verified against source before authoring: the 6/5 member counts, the empty-set ruling (count/count_distinct/sum→0, avg/min/max→null — the register's 'count→0, avg/min/max→null' was correct but incomplete), the ISO-Monday week rule, the two retirement messages, and the engine-only security rule at action-execution.ts:328. Only one enumSource pin fits an item, so AggregationFunction carries it and DateGranularity (also a named export, pinnable in principle) is hand-counted in step 1", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "api-backend.formula-stdlib-matrix", + "title": "CEL formula stdlib: all 27 registered functions give known answers against the BUILT package — calendar-day timezone contract, addMonths month-end clamp, floor(-1.2)==-2, null-dropping joins, mixed int/double arithmetic — and the unregistered-function silent-null fault is RECORDED, never ticked", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "build-time harness (no session — the matrix runs against the built @objectstack/formula package)", + "admin (the live read-path parity leg)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the two stock formula fields for the live leg: showcase_field_zoo.f_formula (field-zoo.object.ts:130) and showcase_project.budget_remaining (project.object.ts:65)", + "runtime metadata authoring (PUT /meta/object on a scratch object) for the stdlib-through-the-live-door leg" + ], + "knownGaps": [ + "The stock formulas exercise only null-guard ternaries and arithmetic — NOT the stdlib catalog. The 27-function matrix therefore runs in a harness against the BUILT package (the same discipline as api-backend.formula-gates), and the live-door leg proves the same evaluator serves reads, not each function individually; a scratch formula field authored through the metadata channel carries two representative stdlib calls onto the live path. Record which oracle each function's verdict rests on." + ] + }, + "steps": [ + "count the registration chain: packages/formula/src/stdlib.ts registerStdLib (:101-236) registers exactly 27 functions by registerFunction call — now, today, daysFromNow, daysAgo, isBlank, coalesce, trim, joinNonEmpty, daysBetween, addDays, addMonths, date, datetime, abs, round, floor, ceil, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty. NOT enumSource-pinnable (a builder chain, not a named z.enum) — re-count the calls in source at run time and fail the matrix stale if the count moved; cross-check the docs catalog (content/docs/data-modeling/formulas.mdx:171-190) advertises the same set, no more, no fewer", + "build the workspace (or confirm dist freshness — the stale-dist trap is the known false-negative here, same as api-backend.formula-gates); the harness must import the BUILT package and record the resolved module path", + "drive every one of the 27 with a pinned now and a known-answer expectation, at minimum: floor(-1.2)==-2 and ceil(-1.2)==-1 (round toward −∞/+∞, NOT toward zero); addMonths(date('2026-01-31'), 1) == 2026-02-28T00:00:00Z (month-end clamp, never a March overflow); daysBetween(a,b) sign convention (b−a, negative when b earlier); date()/datetime() alias parity on the same ISO input; coalesce(null,'x')=='x' but coalesce('', 'x')=='' (null/undefined only); joinNonEmpty(['a', null, ' ', 'b'], '-')=='a-b' (null AND blank-after-trim dropped); isBlank vs isEmpty on null/''/[]; trim(null)==''; upper/lower/contains/startsWith/endsWith on mixed input; matches regex truthiness; len over string/list/map; min/max returning the SMALLER/LARGER OPERAND VERBATIM (type preserved, numeric comparison); abs/round; now() === the pinned instant", + "calendar-day timezone contract (ADR-0053 Phase 2 D1): pin now to an instant whose calendar day DIFFERS between UTC and the reference zone (e.g. 2026-08-30T22:00:00Z with timezone Asia/Shanghai — locally already 08-31) and assert today() == 2026-08-31T00:00:00Z (the reference-tz calendar day expressed as UTC MIDNIGHT — never an instant carrying wall-clock time), daysFromNow/daysAgo offset from that same day; an unknown zone falls back to the UTC calendar day, never throws", + "mixed-arithmetic overloads (registerNumericCoercions, stdlib.ts:262-276): a double-typed record field divided/multiplied by an int literal (record.amount / 100) evaluates to the promoted double instead of faulting 'no such overload' to silent null (#1928), while pure int 7/2 == 3 keeps integer-division semantics untouched", + "live-door parity: GET a showcase_project row and confirm budget_remaining materializes (read-path evaluation); author a scratch object through the metadata channel whose formula field composes two stdlib calls (e.g. joinNonEmpty + upper) and read back the known answer — the stdlib reaching the LIVE read path, not just the harness", + "the expected-fail probe: author (or evaluate in-harness) a formula calling an UNregistered function (e.g. sqrt(4)); capture what actually happens at each door driven. At head the runtime contract is: a formula that compiles but does not EVALUATE yields null — applyFormulaPlan's own `r.ok ? … : null` (packages/objectql/src/engine.ts:1331-1348) — so the read answers 200 with the field null and no error surfaced (#3306 class; stdlib.ts:205-211 documents this exact failure mode as the reason floor/ceil got registered). Record the observed shape verbatim; if an authoring-time gate refuses the formula before it is stored, record THAT as the (better) behavior instead" + ], + "acceptance": [ + { + "clause": "all 27 advertised functions evaluate to their known answers against the BUILT package — the matrix is exhaustive over the hand-counted registration chain, and the docs catalog matches it in both directions (an advertised-but-unregistered function or a registered-but-undocumented one each fail this clause)", + "oracle": "test", + "verify": "harness output: one known-answer row per function, resolved module path pointing into dist/node_modules; the source count and the docs-table cross-check recorded", + "evidence": "the 27-row harness table + the resolved path + the two counts" + }, + { + "clause": "the calendar-day trio (today/daysFromNow/daysAgo) resolves to the REFERENCE-TIMEZONE calendar day expressed as UTC midnight (ADR-0053 D1) — proven with an instant whose calendar day differs between UTC and the zone, so a UTC-only implementation cannot pass by accident; unknown zone falls back to UTC without throwing", + "oracle": "test", + "verify": "the boundary-instant run: today() equals the zone's calendar day at UTC midnight, not the UTC calendar day; the invalid-zone run returns the UTC day", + "evidence": "both runs' outputs vs expectations" + }, + { + "clause": "the documented sharp edges hold exactly: addMonths clamps to the target month's last day (Jan 31 + 1mo → Feb 28); floor/ceil round toward −∞/+∞ (floor(-1.2)==-2); joinNonEmpty drops null AND blank-after-trim entries; coalesce falls back on null/undefined ONLY (empty string passes through); min/max return the operand verbatim with numeric comparison", + "oracle": "test", + "verify": "the five named known-answer rows from the harness table", + "evidence": "the rows" + }, + { + "clause": "mixed int/double arithmetic works without the #1928 silent null — record.amount / 100 evaluates (promoted double) — while pure int/int keeps integer division (7/2 == 3): the overloads fire only when the operands are genuinely mixed", + "oracle": "test", + "verify": "both arithmetic probes: the mixed one returns the double, the int one returns 3", + "evidence": "both results" + }, + { + "clause": "the SAME evaluator serves the live read path: the stock formula fields materialize on GET, and a scratch formula composed of stdlib calls reads back its known answer through the data API — read-your-write included (#5504: the write response carries the materialized formula too)", + "oracle": "api", + "verify": "the scratch-formula GET (and the create response) carry the locally-computed expected value", + "evidence": "the responses vs the expectation" + }, + { + "clause": "EXPECTED-FAIL — the unregistered-function fault is silent-null at head, and this clause exists to RECORD that, not to bless it: the probe's actual behavior (200, field null, no surfaced error — applyFormulaPlan's r.ok?…:null; or a louder authoring-time refusal if one has since landed) is captured verbatim. A run must NOT tick this clause green: silent-null is the standing #3306-class hazard, and the clause flips to a positive assertion only when the platform grows a loud refusal", + "oracle": "api", + "verify": "the probe's captured behavior, compared against the engine.ts:1331-1348 contract; any run recording 'pass' here without a platform change is a false green", + "evidence": "the captured probe trace + the run-record note naming which behavior was observed" + } + ], + "negative": [ + "a harness that silently resolved src/ instead of dist is not-run, not pass — the resolved module path is part of the evidence (the api-backend.formula-gates discipline)", + "today() returning an instant with wall-clock time (or the UTC calendar day under a non-UTC zone) breaks the one representation `record.date == today()` equality depends on — the exact drift ADR-0053 D1 exists to prevent", + "the silent-null probe ticked as a pass is a false green (the clause is expected-fail); equally, filing it as a NEW defect is wrong — it is a known standing class (#3306), recorded on the item" + ], + "traps": [ + "stale-dist", + "clock-skew", + "timezone-boundary" + ], + "source": [ + "packages/formula/src/stdlib.ts:101-236 (registerStdLib — the 27 registerFunction calls; calendarDayUtc :41-51 ADR-0053 D1; addMonthsUtc clamp :83-91; the floor/ceil #3306 note :205-211), :262-276 (registerNumericCoercions #1928)", + "packages/objectql/src/engine.ts:1331-1348 (applyFormulaPlan — evaluate-fault → null, the silent-null contract; the evaluateFormulaField docblock states the compile-throws vs evaluate-nulls split)", + "content/docs/data-modeling/formulas.mdx:171-190 (the advertised catalog the matrix cross-checks)", + "examples/app-showcase/src/data/objects/field-zoo.object.ts:130 + project.object.ts:65 (the stock live-leg formulas)", + "ADR-0053 Phase 2 D1 (calendar-day timezone contract)", + "#3306, #1928, #5504", + "sibling item api-backend.formula-gates (runtime shapes + the date-arithmetic build gate — this item covers the FUNCTION CATALOG that item samples three members of)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the stdlib catalog (27 registered CEL functions) had no matrix: api-backend.formula-gates proves three runtime shapes and the build gate, and nothing asserted the advertised function set evaluates to its documented answers. Count verified by reading stdlib.ts (exactly 27 registerFunction calls — the register's '~27' resolved); the docs table (formulas.mdx) groups them into 18 rows covering the same 27. Not enum-pinnable (a registration chain, not a z.enum) — the count pin is a step-1 source re-count, stated on the item per the enumSource rules. The unregistered-function negative is authored as an expected-fail probe: engine.ts's applyFormulaPlan demonstrably nulls evaluate-faults, so a run records that behavior and must not tick it", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "api-backend.api-methods-verb-gate", + "title": "enable.apiMethods three-state whitelist on the REST boundary: 404 hides apiEnabled:false, 405 OBJECT_API_METHOD_NOT_ALLOWED carries the EFFECTIVE derived set, bulk∧child + writeMode-precise import, legacy 8-value strip-and-WARN (deny-all cliff), the object gate answers ahead of the caller's 403, batch gated per-op before the txn", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin (proves the gate is OBJECT-level: even admin_full_access answers 405/404 on a narrowed object)", + "a non-admin member for the ordering leg (Mei Phone phone.demo@example.com / Ada Auditor auditor.demo@example.com, DEMO_PERSONA_PASSWORD 'showcase123')", + "anonymous (the 401 baseline control)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "stock system objects as free negatives — no provisioning at all: sys_session apiMethods ['get','list'] (sys-session.object.ts:300), sys_device_code ['get'] (sys-device-code.object.ts:159), sys_api_key ['get','list','update'] (sys-api-key.object.ts:369), and sys_oauth_consent enable.apiEnabled false (sys-oauth-consent.object.ts:108) for the 404 fork", + "runtime metadata authoring (manage_metadata) for the scratch-object legs" + ], + "knownGaps": [ + "NO showcase user object narrows apiMethods (grepped examples/app-showcase/src — zero declarations), so the subset-whitelist / deny-all / legacy-strip / bulk∧child legs on a USER object need a scratch object authored through the metadata channel (qa_verb_gate_probe). The system objects above cover the 405/404 forks and the ordering leg with no provisioning; record per clause whether the verdict rests on a system object or the scratch object." + ] + }, + "steps": [ + "read the contract sources: ApiMethod (packages/spec/src/data/object.zod.ts:18-22 — SIX authored primitives get/list/create/update/delete/bulk) and the derivation table (packages/spec/src/data/api-derivation.ts — resolveEffectiveApiMethods :264: undefined/null → unrestricted, [] or stripped-to-empty or NON-ARRAY → deny-all, subset → restricted closure; isApiOperationAllowed :327: bulk∧child, writeMode-precise import, restore/purge permanently closed)", + "free negatives, as ADMIN (the gate keys on the object, not the caller): PATCH /api/v1/data/sys_session/{id} → 405 OBJECT_API_METHOD_NOT_ALLOWED with the allowed[] array; GET /api/v1/data/sys_device_code (list against a get-only whitelist) → 405; GET /api/v1/data/sys_oauth_consent → 404 OBJECT_API_DISABLED (existence hidden); capture full bodies", + "ordering leg: repeat the sys_session PATCH as the NON-ADMIN member and compare with the admin's 405 — the object-level gate answers before any caller-level 403 (the identity-auth.session-list-revoke precedent, '405 before 403'); the anonymous twin answers the 401 floor first", + "scratch subset leg: author qa_verb_gate_probe with enable.apiMethods ['get','list','create']; verify update/delete → 405 whose allowed[] is the EFFECTIVE closure in API_OPERATION_ORDER (get, list, create + derived aggregate/search/export/import — and NEVER restore/purge, and NOT the raw 3-element whitelist); POST createMany → refused (bulk not granted); re-author adding 'bulk' → createMany passes the gate, updateMany still refused (bulk∧child: the child verb must itself be granted)", + "import precision leg (on the scratch object granting create but not update): the import door with writeMode insert clears the gate, writeMode update → 405 (rest-server.ts:7383 — the writeMode-precise check, not the coarse create∨update)", + "legacy strip leg: author enable.apiMethods ['upsert'] and capture BOTH the parse-time warning (object.zod.ts:98-126 — names the FROM→TO guidance 'declare [create,update] — upsert derives from create ∧ update' AND the deny-all cliff sentence, since stripping leaves []) and the runtime effect (every op → 405, allowed: []); then ['get','history'] → kept ['get'], warn names the history derivation; the registration-time diagnostic (packages/objectql/src/registry.ts:1020 warnStrippedLegacyApiMethods) adds the per-object line for schemas that skip Zod", + "batch leg: POST /api/v1/batch with [valid create on showcase_task, update on sys_session]; the whole batch is refused by the per-op gate BEFORE the transaction opens (rest-server.ts:11298-11311) and the valid sibling row never lands", + "both-sides control: GET and list on sys_session as an entitled caller still answer 200 — the whitelist GRANTS what it lists" + ], + "acceptance": [ + { + "clause": "the 404/405 fork holds: enable.apiEnabled false answers 404 OBJECT_API_DISABLED (existence hidden — 'is not exposed via the API'), while a whitelist miss on an exposed object answers 405 OBJECT_API_METHOD_NOT_ALLOWED naming the operation and object — both codes ledgered (error-code-ledger.zod.ts:202-203), and the fork is decided by apiExposureDenialReason's two-step order: apiEnabled first and independently, the whitelist second (#7912)", + "oracle": "api", + "verify": "the sys_oauth_consent 404 vs the sys_session/sys_device_code 405s — codes, messages, and the fork per apiAccessDenialFromEnable (rest-server.ts:316-347)", + "evidence": "the captured bodies" + }, + { + "clause": "the 405 body's allowed[] is the EFFECTIVE derived operation set in API_OPERATION_ORDER — never the raw whitelist: granted primitives plus their derived closure (aggregate/search from list, export from list, import from create/update, history only with trackHistory), with restore/purge absent even for an unrestricted object (permanently-off trash flags)", + "oracle": "api", + "verify": "the scratch object's 405 allowed[] equals effectiveOperationsArray(resolveEffectiveApiMethods({apiMethods:['get','list','create']})) computed locally from the spec table — element-for-element, order included", + "evidence": "the allowed[] vs the locally-computed closure" + }, + { + "clause": "the three-state contract: undefined → unrestricted (a stock object with no whitelist serves every verb — no regression); a whitelist that is [] — authored empty, stripped-to-empty, or a NON-ARRAY value (fails CLOSED, #3545) — → deny-all (every op 405, allowed: []); a subset → exactly its closure. The flipped #3391 semantics: empty means expose NOTHING, not 'no restriction'", + "oracle": "api", + "verify": "one probe per state; the deny-all leg via the stripped-pure-legacy scratch object", + "evidence": "the three per-state traces" + }, + { + "clause": "bulk∧child and writeMode precision: a bulk door (createMany/updateMany/deleteMany, per-object batch — each passes bulkChild, rest-server.ts:11445-11551) requires the bulk primitive AND the child verb; the import door refines to the exact writeMode (insert⇒create, update⇒update, upsert⇒both)", + "oracle": "api", + "verify": "the four scratch-object probes: createMany refused without bulk, allowed with bulk+create, updateMany still refused, import insert-vs-update fork", + "evidence": "the four traces" + }, + { + "clause": "legacy values are STRIPPED-AND-WARNED, never parse-rejected (a PERMANENT compatibility layer — real metadata does not upgrade in lockstep with the spec): the warning carries the per-value FROM→TO prescription and, when stripping empties the whitelist, the loud deny-all-cliff sentence; the runtime effect matches the stripped whitelist exactly", + "oracle": "log", + "verify": "the parse/registration warnings for ['upsert'] (cliff sentence present) and ['get','history'] (kept ['get'], history guidance) + the runtime 405s agreeing with the stripped state; a hard parse rejection of a legacy value is a FAIL of the tolerance contract", + "evidence": "the warning lines + the runtime traces" + }, + { + "clause": "the object gate answers ahead of the caller's authorization: the non-admin's refused verb gets the SAME 405 as the admin's (never a 403 that would leak the whitelist only to the entitled), and the anonymous probe gets the 401 floor before either — while granted verbs still serve the entitled caller (both sides of the gate)", + "oracle": "api", + "verify": "admin-vs-member 405 comparison on sys_session PATCH + the anonymous 401 + the entitled 200 on get/list", + "evidence": "the four traces" + }, + { + "clause": "the cross-object batch door enforces the same gate PER-OP BEFORE opening the transaction: a batch containing one whitelist-refused op is refused up front and no sibling op's row lands (each distinct object×action checked once, rest-server.ts:11298-11311)", + "oracle": "api", + "verify": "the batch refusal + a follow-up read proving the valid sibling create never persisted", + "evidence": "the trace + the absent-row read" + } + ], + "negative": [ + "a whitelist-refused operation that lands data-side effects (row created/updated before the 405) is a write-then-refuse FAIL", + "allowed[] echoing the RAW whitelist (or containing restore/purge) means the wire is leaking the authored form instead of the effective contract the frontend consumes", + "the gate is the EXTERNAL REST boundary only — internal callers (hooks, flows, raw objectql) are deliberately unaffected ('apiEnabled controls automatic API exposure, not data access'); a run must not file an internal write on a narrowed object as a bypass", + "a legacy apiMethods value HARD-REJECTED at parse is a FAIL in the opposite direction — the strip-and-warn tolerance is the contract (#3543: canonicalize-and-warn, never reject)" + ], + "variants": [ + "verb:get", + "verb:list", + "verb:create", + "verb:update", + "verb:delete", + "verb:bulk" + ], + "enumSource": { + "file": "packages/spec/src/data/object.zod.ts", + "export": "ApiMethod", + "expect": 6 + }, + "traps": [ + "wrong-persona", + "dispatcher-vs-hono-route", + "auth-state-leak" + ], + "source": [ + "packages/spec/src/data/object.zod.ts:18-22 (ApiMethod — the six primitives), :34-56 (LEGACY_API_METHODS + LEGACY_API_METHOD_GUIDANCE), :98-126 (stripLegacyApiMethods — strip-and-warn + the deny-all cliff), :265 (the z.preprocess wiring)", + "packages/spec/src/data/api-derivation.ts:264-380 (resolveEffectiveApiMethods three-state, isApiOperationAllowed bulk∧child + writeMode precision, effectiveOperationsArray)", + "packages/rest/src/rest-server.ts:316-347 (apiAccessDenialFromEnable — the 404/405 fork + effective allowed[]), :1276-1331 (enforceApiAccess — external boundary only, default-allow, #3770 unknown-object pass-through), :7383 (writeMode-precise import), :11298-11311 (batch per-op before txn), :11445-11551 (bulk doors with bulkChild)", + "packages/objectql/src/registry.ts:1020 (warnStrippedLegacyApiMethods — the per-object registration diagnostic)", + "packages/spec/src/api/error-code-ledger.zod.ts:202-203 (OBJECT_API_DISABLED, OBJECT_API_METHOD_NOT_ALLOWED)", + "packages/platform-objects/src/identity/sys-session.object.ts:300 + sys-device-code.object.ts:159 + sys-api-key.object.ts:369 + sys-oauth-consent.object.ts:108 (the stock narrowed objects)", + "ADR-0049 / #1889 (shipping a non-enforcing apiEnabled is false security), #3391 (three-state), #3543 (enum shrink + strip), #3545 (non-array fails closed), #7912 (apiExposureDenialReason order)", + "sibling clause: identity-auth.session-list-revoke (the sys_session 405-before-403 precedent this item generalizes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the enable.apiMethods verb gate had no item of its own: identity-auth.session-list-revoke asserts one consequence on one object, and nothing covered the three-state contract, the derived allowed[] closure, bulk∧child, writeMode-precise import, the legacy strip-and-warn (with its deny-all cliff), or the batch per-op ordering. Register corrections folded in during source verification: the strip lives at object.zod.ts:98-126 wired via z.preprocess at :265 (the register's ':70-85' pointed at the guidance table); the enforcement docblock is rest-server.ts:1276-1331 with the denial producer at :316-347; and the stock free negatives are richer than 'three system objects' — sys_session/sys_device_code/sys_api_key give three whitelist shapes and sys_oauth_* adds the apiEnabled:false 404 fork", + "ref": "#sweep-2026-08-30" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index fcb6371c86..bf6950a686 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -95,7 +95,7 @@ "title": "M-of-N quorum approves at the threshold; one rejection vetoes", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": ["three DISTINCT users each holding one of the approver positions"], @@ -103,10 +103,11 @@ "app": "showcase", "requires": ["a quorum (minApprovals: 2) request whose three position approvers resolve to three distinct users"], "knownGaps": [ - "showcase admin holds manager+finance+legal, so the slate collapses to one person and the runtime clamps 2-of-3 to 1-of-1 — M-of-N is not demonstrable on stock seeds (#3358); needs a dedicated fixture or seed change" + "the FLOW half of the fixture is real and seeded — showcase_committee_quorum (2-of-3 quorum over positions manager/finance/legal, examples/app-showcase/src/automation/flows/index.ts:1568-1608) is launched on EXP-DEMO by seed-approval-demo.ts. What still collapses the slate is POSITION ASSIGNMENT, not a missing flow or a pending design call: the admin holds all three committee positions (ADMIN_APPROVAL_POSITIONS = manager/finance/legal/exec, seed-approval-demo.ts:69) and Ada Auditor deliberately holds ONLY auditor (:395), so all three slots resolve to one person and the runtime clamps 2-of-3 to 1-of-1", + "the remaining unblock is a SEED LINE: assign Ada 'finance' (or 'legal') in seed-approval-demo.ts. She has been a real credential login on a stock boot since #9308, and the per-group demo is untouched by the grant — its finance GROUP routes position 'auditor' (flows/index.ts:1542), not position 'finance'. That one line yields a two-distinct-holder slate (admin + Ada), turning minApprovals 2 into a real 2-of-2: the threshold tally, actor distinctness and the one-rejection veto all become demonstrable. Only the 'third approver's pending task is closed by finalization' clause needs more — a THIRD distinct holder (e.g. a legal-holding persona), an equally mechanical seed addition" ] }, - "blocked": { "by": "fixture", "ref": "#3358 (quorum slate collapses onto the admin — showcase design call pending)" }, + "blocked": { "by": "fixture", "ref": "#3358 — slate collapse is now a one-line seed gap, not a design call: the quorum flow ships (showcase_committee_quorum) and Ada is loginable (#9308); assign her a committee position for 2-of-2, add a third holder for full 2-of-3 (see knownGaps)" }, "steps": [ "boot showcase isolated (dogfood §0); sign in as the dev admin", "runnable today (the clamp contrast): GET the seeded showcase_committee_quorum request on EXP-DEMO (behavior quorum, minApprovals 2 over manager/finance/legal — all resolving to the admin); record the collapsed pending slate", @@ -162,7 +163,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358; carried the fixture blocker forward explicitly", "ref": "#3358" }, - { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 3, "date": "2026-08-30", "change": "blocked ref re-worded to price the gap honestly: the old 'showcase design call pending' read as an open-ended product decision, but the quorum flow fixture ships (showcase_committee_quorum, 2-of-3 over manager/finance/legal) and Ada Auditor has been a real login since #9308 — the slate collapse survives only because seed-approval-demo.ts gives the admin all three committee positions (:69) and Ada only auditor (:395). knownGaps now states the exact remaining cost (one seed line assigning Ada a committee position → 2-of-2 demonstrable; a third distinct holder for the full 2-of-3), verified against source including the per-group demo's non-collision (its finance group keys on position 'auditor')", "ref": "#sweep-2026-08-30" } ] }, { @@ -389,7 +391,7 @@ "title": "Every approval action executes its REST route and produces the expected state transition plus a timeline entry", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["dev admin (pending approver on the seeded requests; submitter of the invoice request)"], @@ -398,6 +400,9 @@ "requires": [ "the three seeded demo requests (invoice unanimous / EXP-DEMO quorum / EXP-2001 per-group) plus fresh showcase_budget_approval requests raised on demand by PATCHing a showcase_project budget above 100000 (budget != previous.budget)", "showcase_budget_approval's manager step declares the ADR-0044 revise loop (maxRevisions: 2) and lockRecord: false; its exec step (budget > 500000) declares NO revise edge and lockRecord: true — both sides of two gates in one flow" + ], + "knownGaps": [ + "the ORG-LESS request the READ_BACK_FAILED clause needs arises only from schedule / time-relative / api-trigger runs, which open their requests with organization_id null by construction (#10131; #9132 pinned that behaviour rather than repairing it) — no seeded showcase flow opens one on demand, so that clause scores blocked(fixture) on stock seeds until a scratch schedule- or api-triggered approval flow provisions it. The behaviour itself is unit-pinned (packages/plugins/plugin-approvals/src/approval-service.test.ts, the #12769 org-filtered read-back describe), so a run may cite the pin for the mechanism while recording the live-HTTP leg as the blocked half" ] }, "variants": ["approve", "reject", "reassign", "revise (send-back)", "resubmit", "recall", "remind", "request-info", "comment"], @@ -469,6 +474,12 @@ "oracle": "api", "verify": "PATCH the project while each step is pending: refused under exec_review, accepted under manager_review (the objectui#2902 pair)", "evidence": "the two PATCH responses" + }, + { + "clause": "the org-scope read-back gate (#12769, landed #13181): deciding a request whose row is invisible inside the caller's organization narrowing answers a LOUD named refusal — the decision IS recorded, only the echo is refused — never a well-formed 200 whose declared-non-null `request` is null on the wire", + "oracle": "api", + "verify": "decide an ORG-LESS request (organization_id null — the shape every schedule / time-relative / api-trigger run mints, #10131) as an org-scoped caller the override gate admits (isOverrideActor: 'a null-org request is global and any admin may release it'): the wire answer is the 500 APPROVAL__FAILED envelope whose error text leads with READ_BACK_FAILED and names the remedy ('read the request back with a system or matching-organization context'); a follow-up re-read under a system/matching-org context shows the decision landed — status moved, the action row present, nothing rolled back. READ_BACK_FAILED is deliberately NOT in handleApprovalError's prefix map, so the generic 500 envelope carrying the named text is the designed shape, not a bug to file", + "evidence": "the refused decision response + the system-context re-read showing the recorded write" } ], "negative": [ @@ -483,11 +494,14 @@ "packages/rest/src/rest-server.ts (flowMoveRoute: revise=pending approver, resubmit=submitter; threadRoute access per action; recall submitter-only)", "packages/plugins/plugin-approvals/src/approval-service.ts (ApprovalService.resubmit — 'traversal walks the declared back-edge into the approval node, whose executor opens the round-N+1 request'; the resubmitted row's status is never rewritten. sendBack counts prior rounds as returned siblings on flow_run_id + flow_node_id)", "packages/spec/src/contracts/approval-service.ts (APPROVAL_STATUSES: pending|approved|rejected|recalled|returned)", - "examples/app-showcase/src/automation/flows/index.ts (BudgetApprovalFlow — ADR-0044 revise loop, maxRevisions 2, exec step without a revise edge, lockRecord pair)" + "examples/app-showcase/src/automation/flows/index.ts (BudgetApprovalFlow — ADR-0044 revise loop, maxRevisions 2, exec step without a revise edge, lockRecord pair)", + "packages/plugins/plugin-approvals/src/approval-service.ts:4750-4787 (readBackRequest — the write path's post-mutation echo; #12769 docblock: org-less rows are a live state, the null echo used to escape behind the callers' fresh! non-null assertions, now it refuses loudly; landed as PR #13181)", + "packages/rest/src/rest-server.ts:10884-10909 (handleApprovalError prefix map — READ_BACK_FAILED deliberately unmapped, so it reaches the wire as the generic 500 APPROVAL__FAILED envelope carrying the named text)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial — decision-action matrix derived from the approvals REST route ledger and the ADR-0044 revise/resubmit flow shape", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-11", "change": "corrected the resubmit transition the item asserted: it does NOT move the original returned→pending — the approval node's re-entry mints a round-N+1 request while the original stays terminally returned (source-confirmed as by design). Split the old combined revise/resubmit clause into three (revise parks · resubmit mints · original stays returned) and sharpened the maxRevisions verify to name which request the auto-reject lands on. Fixes the sole reason #7517 scored this item PARTIAL", "ref": "#7530" } + { "revision": 2, "date": "2026-08-11", "change": "corrected the resubmit transition the item asserted: it does NOT move the original returned→pending — the approval node's re-entry mints a round-N+1 request while the original stays terminally returned (source-confirmed as by design). Split the old combined revise/resubmit clause into three (revise parks · resubmit mints · original stays returned) and sharpened the maxRevisions verify to name which request the auto-reject lands on. Fixes the sole reason #7517 scored this item PARTIAL", "ref": "#7530" }, + { "revision": 3, "date": "2026-08-30", "change": "added the READ_BACK_FAILED clause (#12769, landed #13181 2026-08-29): all ten fresh! non-null assertions on the post-write echo are gone — deciding an org-less request (the schedule/api-trigger shape, #10131) as an org-scoped caller now answers a loud named refusal with the write recorded, never a malformed 200 with request:null. Fixture reality recorded in knownGaps: no seeded flow opens an org-less request on demand, so the live-HTTP leg is blocked(fixture) while the unit pin covers the mechanism", "ref": "#13181" } ] }, { @@ -1068,6 +1082,338 @@ "history": [ { "revision": 1, "date": "2026-08-10", "change": "initial — the bell breakdown (objectui#4073) and Home's card target had no checklist coverage", "ref": "#7331" } ] + }, + { + "id": "approvals.email-action-token-door", + "title": "The anonymous /approvals/act door: a minted one-tap token renders a confirm page and one POST decides as exactly the bound approver — every dead-token shape is a designed refusal page with no state change", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["dev admin (submitter of the seeded invoice request — remind is submitter-only, and the remind fan-out is what mints the tokens)", "the ANONYMOUS bearer of an action link: a clean HTTP client holding NO session cookie and NO bearer token"], + "fixtures": { + "app": "showcase", + "requires": [ + "a pending request the admin SUBMITTED (the seeded invoice dual sign-off request — seed-approval-demo.ts submits it as the admin) so POST /:id/remind is permitted; the reminder fan-out mints per-approver approve/reject tokens (ADR-0043) for every CONCRETE pending approver", + "token capture WITHOUT a mail harness: messaging's emit() persists the notification payload verbatim on the sys_notification event row (writeEvent, packages/services/service-messaging/src/messaging-service.ts:1000-1022; NOTIFICATION_EVENT_OBJECT = 'sys_notification', :25) — so after a remind, GET /api/v1/data/sys_notification filtered to topic approval.reminder + source_id= as the admin and read the two /api/v1/approvals/act?token=... URLs out of payload.actions. The raw tokens exist ONLY there and in the outbound mail: sys_approval_token stores SHA-256 hashes (a DB leak yields no usable links)" + ], + "knownGaps": [ + "the EMAIL-RENDERING leg (the Approve/Reject links arriving as buttons in an actual message) is not covered by the sys_notification capture — it needs the dev log mail transport the forgot-password item uses (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport, see identity-auth.self-service-password-reset fixtures); without it that one leg is blocked(fixture) while every token/door clause still runs off the persisted payload", + "the EXPIRED leg has no in-session clock: ACTION_TOKEN_TTL_MS is 72h (approval-service.ts:276) and issueActionTokens' ttlMs override is not REST-reachable — drive expiry by backdating the minted sys_approval_token row's expires_at over the data API as the admin (a data row, writable), and record that manipulation in the run evidence" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "mint: POST /api/v1/approvals/requests/:id/remind on the admin-submitted invoice request; then read the minted rows — GET /api/v1/data/sys_approval_token filtered to request_id: one approve + one reject row PER concrete pending approver, each carrying token_hash (never a raw token), approver_id, expires_at ≈ now+72h, consumed_at null", + "capture the raw links: GET /api/v1/data/sys_notification (topic approval.reminder, source_id = the request id) and extract the two /api/v1/approvals/act?token=... URLs from payload.actions", + "switch to a CLEAN anonymous client (no cookies, no Authorization header — prove it: GET /api/v1/approvals/requests from the same client answers 401); GET the approve URL — 200 HTML confirm page naming the action, the acting approver id and the request summary, with a POST form; GET it again, then re-read the request and token row as admin: nothing changed, consumed_at still null (mail-gateway prefetch safety — the GET never mutates)", + "POST the form body (token=) anonymously — 200 result page 'Approved · 已通过'; as admin re-read the request (status moved / group tally advanced) and GET /:id/actions — the approve row's actor is the BOUND approver_id, comment 'Via action link'", + "replay: POST the same token again — 'Already used · 链接已使用' page; no new action row, no state movement", + "dead-slot leg: on a second pending request mint tokens, decide it through the normal UI/API first, then GET the leftover token URL — 'Already decided · 请求已处理' page; and on a third, reassign the bound approver away first, then GET — 'No longer your approval · 已不在你名下' (the link died with the slot, ADR-0043 invalidation)", + "garbage: GET and POST with token=garbage — 'Invalid link · 链接无效' page, 200, no summary rows", + "expiry: mint fresh tokens, backdate that sys_approval_token row's expires_at to a past instant via the data API, GET the URL — 'Link expired · 链接已过期' page", + "scope probe: replay the raw token as a credential everywhere else — Authorization: Bearer on GET /api/v1/approvals/requests and on GET /api/v1/data/showcase_invoice — every such call is unauthenticated (401); the token opens nothing but the act door" + ], + "acceptance": [ + { + "clause": "minting is approver-bound and hashed at rest: remind fans out one approve + one reject token per CONCRETE pending approver (literal type:value slots get a plain nudge, no token), stored as SHA-256 hashes with approver_id and expires_at — and issueActionTokens refuses a non-pending approver with FORBIDDEN", + "oracle": "api", + "verify": "the sys_approval_token rows after the remind: token_hash present (64 hex chars), no raw-token column, approver_id ∈ pending_approvers, action ∈ {approve, reject}; the sys_notification payload carries the raw URLs the hashes verify against", + "evidence": "token-row read + notification-payload read" + }, + { + "clause": "the GET confirm page renders session-less and MUTATES NOTHING — repeated GETs leave the request, its actions timeline and the token's consumed_at byte-identical (a mail gateway's link prefetch must never approve a request)", + "oracle": "api", + "verify": "before/after reads of GET /:id, /:id/actions and the token row around two anonymous GETs of the confirm URL — no delta; the page itself came back 200 text/html with the POST form (peekActionToken validates without consuming)", + "evidence": "the confirm-page response + before/after reads" + }, + { + "clause": "one anonymous POST decides as exactly the bound approver on exactly the bound request: the decision is attributed like a UI decision — the approve action row's actor is the token's approver_id (never null, never the submitter), comment 'Via action link', and the request/tally/flow-run all move exactly as a session decision would", + "oracle": "api", + "verify": "post-redemption reads: request status/tally moved; /:id/actions carries one approve row with actor = the bound approver_id ('the token IS the authentication' #3783 — the decision context carries that userId, so the status mirror and every cascade attribute to them)", + "evidence": "the redemption response + request/actions re-reads" + }, + { + "clause": "redemption is single-use and consume-FIRST: the second POST answers the 'Already used' page with no second action row — and because the token is burned before the decide, a decide that fails still burns it (replay-safe by design; a burned-but-undecided token is the designed outcome, not a defect to file)", + "oracle": "api", + "verify": "token row's consumed_at set after the first POST; second POST → consumed page; actions timeline count unchanged", + "evidence": "token-row read + the replayed response + actions count" + }, + { + "clause": "every dead-token shape answers its DESIGNED 200 refusal page with zero state change: invalid / expired / consumed render WITHOUT request summary rows; not_pending / already-decided and not_approver / reassigned-away render theirs WITH the summary (the bearer legitimately held that link) — and none of the five moves any state", + "oracle": "network", + "verify": "one capture per RESULT_COPY shape (invalid, expired, consumed, not_pending, not_approver) — HTTP 200 text/html, the matching bilingual title, summary-row presence per the rule above; paired before/after request reads show no delta. The 200-on-refusal is by design (a human-facing page reached from a mail client) — a run must not file the missing 4xx as a finding", + "evidence": "the five refusal captures + paired reads" + }, + { + "clause": "the token grants nothing else: presented as a bearer credential on any authenticated route it is a stranger — the platform's auth stack never resolves it", + "oracle": "api", + "verify": "Authorization: Bearer on GET /api/v1/approvals/requests and one data route answers 401 exactly like an anonymous call; no session is minted anywhere", + "evidence": "the 401 responses" + } + ], + "negative": [ + "a GET of the confirm URL that consumes the token or records a decision is a FAIL — the prefetch-safety split (GET renders, POST decides) is the whole reason the door has two verbs", + "a replayed token accepted twice — two action rows, or a tally moved twice — is a FAIL of the consume-first design", + "an invalid/expired/consumed refusal page that leaks the request summary (process, record, requester) to a bearer whose token never resolved is a FAIL — those three shapes render no summary rows by design", + "a raw token readable out of sys_approval_token (a raw-token column, or a hash that is really the token) is a FAIL of the hashed-at-rest claim" + ], + "traps": ["dispatcher-vs-hono-route", "auth-state-leak"], + "source": [ + "packages/plugins/plugin-approvals/src/approvals-plugin.ts:349-371 (the raw Hono mount at /api/v1/approvals/act — GET renders via peekActionToken, POST redeems; mounted on kernel:ready off the canonical http.server handle, #4251 B5)", + "packages/plugins/plugin-approvals/src/approval-service.ts:3374-3474 (actionLinkUrl · issueActionTokens: hashed-at-rest + FORBIDDEN for non-pending approvers · resolveActionToken: invalid/consumed/expired/not_pending/not_approver chain · peekActionToken 'validate WITHOUT consuming' · redeemActionToken 'consume the token FIRST … then decide as the bound approver', #3783 the-token-IS-the-authentication attribution); ACTION_TOKEN_TTL_MS = 72h (:276)", + "packages/plugins/plugin-approvals/src/approval-service.ts:3322-3346 (the remind fan-out is the mint path: per-approver Approve/Reject action links on topic approval.reminder; literal slots fall back to a tokenless nudge)", + "packages/plugins/plugin-approvals/src/action-link-pages.ts (ADR-0043 session-less bilingual pages; 'The GET page NEVER mutates'; RESULT_COPY :84-92 — which refusal shapes carry summaryRows and which do not)", + "packages/services/service-messaging/src/messaging-service.ts:1000-1022 + :25 (writeEvent persists the payload on sys_notification — the stock-boot token-capture seam this item's fixtures rest on)", + "packages/plugins/plugin-approvals/src/sys-approval-token.object.ts (the token store)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "initial — the anonymous actionable-link door (ADR-0043) had no checklist coverage despite being an unauthenticated decision path. Authored from source with one register correction: the hunter proposed an emailed-link capture harness as the fixture, but messaging persists the reminder payload (raw action URLs included) on the sys_notification event row, so every token/door clause is stock-runnable; only the email-rendering leg still wants the LogTransport capture (recorded in knownGaps)", "ref": "#sweep-2026-08-30" } + ] + }, + { + "id": "approvals.approver-resolution-matrix", + "title": "Every approver type resolves the slate it declares — department includes all descendants, deprecated role warns and resolves, and a slate with no concrete person takes the declared onEmptyApprovers fork, never a fake approval", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["dev admin (authors the scratch flows; org owner)", "Ada Auditor / Mei Phone (real logins since #9308 — directory rows the graph legs resolve to)"], + "fixtures": { + "app": "showcase", + "requires": [ + "scratch ACTIVE autolaunched flows in a WRITABLE package, one per uncovered approver leg, each with a single approval node whose approvers carry the type under test (shape them after examples/app-showcase/src/automation/flows/approver-bindings.flow.ts, but note the caveat in knownGaps: that file is a DRAFT designer specimen, not a runnable fixture)", + "directory rows for the graph legs: the sys_business_unit tree IS seeded (the real org tree — examples/app-showcase/src/data/seed/index.ts:231-249), but unit MEMBERSHIP (sys_business_unit_member) and teams (sys_team / sys_team_member) are runtime writes — insert them over the data API as the admin before triggering the department/team flows", + "a manager chain for the manager leg: set a submitter's sys_user.manager_id (runtime PATCH)" + ], + "knownGaps": [ + "approver-bindings.flow.ts is status:'draft' with EMPTY values, deliberately — its own docblock says an active flow over those kinds would resolve to nobody and 're-teach the bug' (#3508). It exercises the DESIGNER's Value pickers, so it cannot serve as the runtime specimen the register hypothesized; every leg this matrix drives needs its own scratch active flow (the writable-package gap approvals.ooo-delegation-reroute already records)", + "position, org_membership_level and expression already have LIVE stock coverage — the seeded per-group/quorum/budget flows route position, and showcase_dynamic_approval routes org_membership_level + expression (see approvals.dynamic-approver-routing). This matrix owns the missing legs (department+descendants, team, field, manager, user, role, queue, empty-slate forks); do not double-run the covered three — cite the sibling items' runs instead", + "sys_team is EMPTY on a stock boot (the seeded 'Team' rows are the demo showcase_team object, not the platform sys_team) — the team leg blocks(fixture) until the run inserts sys_team + sys_team_member rows at runtime" + ] + }, + "enumSource": { "file": "packages/spec/src/automation/approval.zod.ts", "export": "ApproverType", "expect": 10 }, + "variants": ["manager", "position", "department (+ ALL descendant units)", "team", "field (multi-value fan-out)", "expression", "org_membership_level", "role (deprecated → org_membership_level: warn + resolve)", "user", "queue (dead slot, #3508)"], + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin; provision the directory rows (BU memberships, a team + members, a manager_id chain) over the data API and record their ids", + "author + register the scratch flows in a writable package; per leg: trigger the flow, GET /api/v1/approvals/requests?status=pending, GET /:id and read pending_approvers (and pending_approver_names)", + "department leg: point the approver at a PARENT unit whose descendant units hold the members — the slate must union the parent's members with every descendant's, deduped; deactivate a descendant and re-trigger: its members drop out (inactive units are skipped)", + "field leg: a multi-select user field naming two users — two slots, in order, never one 'u1,u2' literal", + "manager leg: the submitter's manager_id resolves; user leg: the literal id resolves", + "role leg: author {type:'role', value:'owner'} (it still parses for the deprecation window); read the boot/trigger log for the ADR-0090 D3 deprecation warn naming the canonical spelling, and assert the slate equals what {type:'org_membership_level', value:'owner'} resolves on the same org", + "queue leg: a stored flow carrying {type:'queue'} (author it directly through the metadata channel if the designer refuses — the designer withholding it IS the xEnumDeprecated contract) — trigger: the slot resolves to nobody, the request opens carrying only the literal queue: slot, and the log carries the #3508 'not implemented — the slot resolves to nobody' warn", + "empty-slate forks, one scratch flow per policy over an UNSTAFFED position: onEmptyApprovers absent/admin_rescue → the request still opens (decidable only via the #3424 privileged override) and the loud 'resolved to no concrete approver' warn lands; 'fail' → the run fails with NO_APPROVERS and no request row; 'auto_approve' → NO request row, the run continues down the approve edge with output.autoApproved=true and the auto-approve warn lands", + "throughout: read the trigger-time log — every graph expansion to nobody must carry the #3807 'expanded to nobody' warn naming type and value" + ], + "acceptance": [ + { + "clause": "per-variant: each approver type resolves exactly the slate its declaration describes at node entry — every variant individually verified against the directory rows planted for it", + "oracle": "api", + "verify": "per-variant table: the flow's pending_approvers vs the planted rows (position → sys_user_position holders by machine NAME; org_membership_level → sys_member.role tier; team → sys_team_member; user/field/manager → the named ids, OOO-substituted)", + "evidence": "per-variant request reads + the planted-row reads" + }, + { + "clause": "department resolves the unit AND every descendant: BFS over parent_business_unit_id, membership unioned across the whole subtree, deduped; inactive units (and an inactive/unknown root) contribute nobody", + "oracle": "api", + "verify": "parent-unit approver's slate ⊇ a descendant-only member; after deactivating that descendant and re-triggering, the member is gone (expandBusinessUnitUsers walks active descendants only)", + "evidence": "the two slates + the BU rows" + }, + { + "clause": "a multi-value field approver fans out one slot per id — never a collapsed CSV literal that would mint one bogus approver and skip every OOO delegate", + "oracle": "api", + "verify": "pending_approvers carries each id from the field value as its own entry (#3447: 'collapsing to String(...) would mint one bogus approver id')", + "evidence": "the request read vs the field value" + }, + { + "clause": "deprecated 'role' still parses for the deprecation window, warns with the canonical prescription, and resolves IDENTICALLY to org_membership_level", + "oracle": "log", + "verify": "the ADR-0090 D3 warn ('approver type role is deprecated — author org_membership_level instead') appears at resolution, and the two spellings' slates are equal on the same org (canonicalApproverType maps role → org_membership_level before any lookup)", + "evidence": "the warn line + the two equal slates" + }, + { + "clause": "the empty-slate fork honors the declared policy: admin_rescue (the default) opens the request + warns loudly (decidable only via the privileged override); 'fail' kills the node with NO_APPROVERS and opens nothing; 'auto_approve' opens nothing and resumes down the approve edge with output.autoApproved=true — 'empty' meaning no CONCRETE person, so a slate of only type:value literals takes the fork too", + "oracle": "api", + "verify": "per policy: request-row presence/absence, run outcome, and the matching warn/error text ('resolved to no concrete approver…'); the admin_rescue request's pending_approvers holds only the literal slot", + "evidence": "per-policy request/run reads + log lines" + }, + { + "clause": "queue is a dead slot that stays LOUD, never a fake approval: it resolves to nobody, falls back to the literal queue: slot, warns #3508 — and with default admin_rescue the request waits for an override rather than passing", + "oracle": "log", + "verify": "the 'queue is not implemented — the slot resolves to nobody (#3508)' warn + the request read showing the literal slot and status still pending", + "evidence": "warn line + request read" + } + ], + "negative": [ + "a graph approver expanding to nobody with NO warn is a FAIL even though the literal-slot fallback itself is by design — the silence is what let #3807 hide behind a permanently stuck approval", + "an empty slate that finalizes approved under admin_rescue (or under the absent-key default) is a FAIL — only the opt-in auto_approve policy may wave a record through, and it must say so in the log", + "role refused at parse, or resolving a different slate than org_membership_level, is a FAIL during the deprecation window — the map (DEPRECATED_APPROVER_TYPES) is the contract", + "a department approver resolving ONLY direct members while descendants hold people is a FAIL — the descendant walk is the documented semantic (the spec enum's own docline: 'Members of a department + all descendant departments')" + ], + "traps": ["seed-data-thin", "wrong-persona"], + "source": [ + "packages/spec/src/automation/approval.zod.ts:31-73 (ApproverType, 10 members incl. deprecated role + dead queue), :81-88 (DEPRECATED_APPROVER_TYPES role→org_membership_level + canonicalApproverType), :100-103 (NON_AUTHORABLE_APPROVER_TYPES), :739-754 (onEmptyApprovers admin_rescue|fail|auto_approve, default admin_rescue)", + "packages/plugins/plugin-approvals/src/approval-service.ts:1032-1092 (expandApprovers — per-spec group tagging; expression resolved outside the flat contract), :1102-1226 (resolveApproverSpec — role warn :1114-1119, per-type branches, queue warn :1204-1208, #3807 expanded-to-nobody warn :1219-1223, literal type:value fallback :1225), :1625-1666 (expandBusinessUnitUsers — active-descendant BFS + membership union), :2127-2157 (the empty-slate fork: 'empty' = no concrete person, NO_APPROVERS / autoApproved / admin_rescue warn)", + "examples/app-showcase/src/automation/flows/approver-bindings.flow.ts (the #3508 DESIGNER specimen — draft, empty values, its docblock is the evidence the runtime legs need scratch flows)", + "examples/app-showcase/src/data/seed/index.ts:231-249 (BU tree seeded; membership is a runtime admin action by design)", + "#3424 (privileged override — the admin_rescue escape hatch), #3447 P2, #3508, #3807, ADR-0090 D3" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "initial — the approver-type matrix had no item: siblings cover position (seeded flows) and org_membership_level/expression (dynamic-approval) but department-with-descendants, team, field fan-out, manager, literal user, the role deprecation window, the dead queue slot and all three onEmptyApprovers forks were untested. One register correction: approver-bindings.flow.ts is a draft designer specimen (empty values, status draft by documented design), not a runnable user/manager/field fixture — every uncovered leg needs a scratch active flow, recorded in knownGaps", "ref": "#sweep-2026-08-30" } + ] + }, + { + "id": "approvals.status-mirror-field", + "title": "approvalStatusField mirrors every request transition onto the business record — through the record lock's whitelist, attributed to the acting user", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["dev admin (authors the scratch flow; decides and recalls)", "a second real login (Ada) as submitter where the recall/pending attribution needs a non-deciding identity"], + "fixtures": { + "app": "showcase", + "requires": [ + "a scratch ACTIVE flow in a WRITABLE package whose approval node declares config.approvalStatusField naming a text field on the trigger object (the spec says the field 'should be readonly on the object' — declare it so, the mirror lands anyway), plus a SECOND node/flow WITHOUT the key for the never-writes side", + "for the failure-tolerance clause: a third scratch flow whose approvalStatusField names a NONEXISTENT field" + ], + "knownGaps": [ + "NO showcase flow declares approvalStatusField (grepped examples/ — zero hits), so nothing here runs on stock seeds: the whole item rides the scratch-flow route, which needs the writable/scratch package gap approvals.ooo-delegation-reroute already records. A one-line addition to a seeded showcase flow (e.g. BudgetApprovalFlow) plus a readonly mirror field on showcase_project would retire this gap", + "the 'returned' leg needs the ADR-0044 revise loop on the SAME node that declares the mirror — copy BudgetApprovalFlow's manager step shape (maxRevisions) into the scratch flow, or the send-back transition is not reachable" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); author + register the scratch flows; trigger the mirrored flow", + "submit: while the request is pending, GET the trigger record — the mirror field reads 'pending', and the record's updated_by is the SUBMITTER (the pending stamp is attributed to whoever the row calls submitter, #3783)", + "decide: approve — the field flips to 'approved' with updated_by = the decider; re-trigger and reject — 'rejected'; re-trigger and recall as the submitter — 'recalled'; re-trigger, send back (revise) — 'returned' (NOTE: the service writes this fifth value at :3002-3006 though the spec docblock lists only four — assert the actual behavior); drive the revise loop past maxRevisions — the auto-reject stamps 'rejected'", + "lock contrast: with lockRecord true (the default) and the request pending, PATCH the record as the submitter — refused (RECORD_LOCKED class); yet the 'pending' mirror already landed on that same locked record — the lock hook whitelists a write whose ONLY change is the configured approvalStatusField", + "cascade identity: declare a record-change flow on the trigger object with runAs:'user' reacting to the mirror value — after an approve, it runs with the DECIDER as trigger user; then force a dead-run release (or cite the automated pin) — the sweep's user-less 'recalled' mirror leaves the same runAs:'user' flow refused", + "no-key side: drive the second flow (no approvalStatusField) end to end — the field is never written at any transition", + "failure tolerance: drive the third flow (nonexistent field) — the decision still finalizes; the log carries the '[approvals] mirrorStatusField failed' warn" + ], + "acceptance": [ + { + "clause": "submit stamps 'pending' and each transition stamps its terminal value: approve→approved, reject→rejected, recall→recalled, send-back→returned, maxRevisions auto-reject→rejected — so lists and views can filter the business object on the mirror alone", + "oracle": "api", + "verify": "GET the trigger record after each transition: the declared field carries exactly the transition's value (write sites: pending :2258-2270 · final :2518-2522 · recalled :2842-2846 · auto-reject :2962-2966 · returned :3002-3006). The 'returned' value is service behavior the spec docblock omits — assert it lands, and do not file the docblock mismatch as a runtime bug", + "evidence": "record reads per transition" + }, + { + "clause": "the mirror crosses the record lock by WHITELIST, not by blanket elevation: under lockRecord:true a pending-locked record refuses ordinary edits while the mirror-field-only system write lands ('block, EXCEPT when the only changed field is the configured approvalStatusField')", + "oracle": "api", + "verify": "the submitter's PATCH while pending is refused; the same record already shows the 'pending' mirror value — both facts on one record read + one refused call", + "evidence": "the refused PATCH + the record read" + }, + { + "clause": "the mirror write carries the ACTING user (#3783): a human-caused transition attributes updated_by to that human and fires the object's record-change flows with them as trigger user — so a runAs:'user' cascade works with RLS enforced; the machine-driven transitions (dead-run sweep, SLA auto-decision) stay deliberately user-less and the same cascade is refused", + "oracle": "test", + "verify": "run the automated pin (real kernel, real SQL store — the mirror produced by an actual decision, the negative fired off the dead-run sweep) and cite its output; a live run may additionally observe updated_by on the record after a decision", + "evidence": "the pinned test output (+ optional live updated_by read)", + "$comment": "the pin covers the seam a unit test of any single hop cannot see" + }, + { + "clause": "a node WITHOUT approvalStatusField never writes the field — the mirror is strictly opt-in (omitted ⇒ status is exposed only via sys_approval_request)", + "oracle": "api", + "verify": "drive the unmirrored flow through submit + a decision: the field (present on the object, undeclared on the node) stays untouched at every read", + "evidence": "record reads across the unmirrored lifecycle" + }, + { + "clause": "a failing mirror is non-fatal and LOUD: the decision finalizes (request status, action row, run resume all land) while the mirror failure surfaces as the named warn — never a rolled-back decision, never silence", + "oracle": "log", + "verify": "with approvalStatusField naming a nonexistent field: the decide answers 2xx, the request finalizes, and the log carries '[approvals] mirrorStatusField failed: …' (the try/catch at :1976-1981)", + "evidence": "the successful decision reads + the warn line" + } + ], + "negative": [ + "a mirror write that changes ANY field beyond the declared one, or that lands on a node that declared no mirror, is a FAIL — the lock whitelist and the opt-in are both scoped to exactly that one field", + "a decision rolled back (or answering 5xx) because the mirror write failed is a FAIL — the mirror is best-effort by design and the warn is its contract", + "a runAs:'user' cascade running as SYSTEM off a human decision's mirror (or running at all off the sweep's user-less mirror) is a FAIL of the #3783 attribution the integration pin exists to hold" + ], + "traps": ["seed-data-thin", "eventual-consistency"], + "automated": { "kind": "test", "ref": "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts" }, + "source": [ + "packages/spec/src/automation/approval.zod.ts:723-737 (approvalStatusField — 'Should be readonly on the object. Omitted ⇒ status is exposed only via sys_approval_request'; the docblock's four-value list, which the service's fifth value 'returned' outgrew)", + "packages/plugins/plugin-approvals/src/approval-service.ts:1935-1982 (mirrorStatusField — elevated but not anonymous #3783: system write carrying the actor's userId; actorId null only for machine transitions; warn-not-throw on failure) + the six call sites :2258-2270, :2518-2522, :2842-2846, :2962-2966, :3002-3006, :3871-3877 (dead-run sweep, deliberately user-less)", + "packages/plugins/plugin-approvals/src/lifecycle-hooks.ts:17-20 (the record lock's whitelist: 'block, EXCEPT when the only changed field is the configured approvalStatusField — so the status mirror is never blocked')", + "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts (the cascade-identity pin: real kernel + real SQL store, positive decider-identity case and the load-bearing user-less negative)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "initial — the opt-in status mirror had no coverage. Grounded in the six service write sites (including 'returned', which the spec docblock omits — recorded so a run asserts actual behavior), the lock hook's mirror-field-only whitelist, and the #3783 acting-user attribution the existing integration test pins; no showcase flow declares the key, so the fixture rides the scratch-flow route (knownGaps)", "ref": "#sweep-2026-08-30" } + ] + }, + { + "id": "approvals.inbox-keyboard-flow", + "title": "Inbox keyboard flow: j/k and arrows move focus, Enter opens, x selects, ←/→ walk the drawer — and a/r quick-decide through the SAME server transition as the buttons, gated by quick-decidability", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["dev admin (pending approver on the seeded demo requests — several quick-decidable rows plus the dynamic-approval stage-1 row as the NOT-quick-decidable contrast)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a bundled console at the current pin (see approvals.account-app-entry fixtures) and the seeded demo requests, giving the 待我审批 tab multiple rows", + "one pending row that declares decisionOutputs — retitle a showcase_announcement to open showcase_dynamic_approval's stage-1 request (REQUIRED next_reviewers output): quickDecidable is isActionable AND no declared decision outputs (#2829), so this row is the stock a/r-must-not-fire contrast" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin; open the inbox via the account app's Approvals entry; confirm the hint bar renders ('Keyboard: j/k move · Enter open · x select · a approve · r reject')", + "press j/j/k and ArrowDown/ArrowUp — the focus ring walks the FILTERED list order; press Enter — the focused row's drawer opens (verify the drawer's request identity against GET /api/v1/approvals/requests/:id, not the highlight)", + "with the drawer OPEN press ArrowLeft/ArrowRight — the drawer walks to the previous/next visible row without returning to the list; close the drawer", + "focus a quick-decidable row and press r — the shared confirm dialog opens (nothing posted yet); confirm — capture POST /api/v1/approvals/requests/:id/reject and re-read the request + /:id/actions", + "press a on another quick-decidable row — approve dialog → confirm → POST /:id/approve; re-read", + "ownership guards: click into the search input and type j/k/a — the list focus does not move and nothing decides; with a confirm dialog open press j/Enter — inert; the same while the drawer sheet is open (the list handler yields to selectedId)", + "gating: focus the dynamic-approval stage-1 row (declared decisionOutputs) and press a and r — NOTHING happens: no dialog, no POST (those decisions need the drawer's typed-output dialog)", + "press x and space on a focused row in the 待我审批 tab — selection toggles and feeds the bulk bar; switch to the 我发起的 tab and press x — inert (selection is pending-tab-only)" + ], + "acceptance": [ + { + "clause": "j/k and ArrowDown/Up move a visible focus ring over the filtered list, clamped at both ends, and Enter opens exactly the FOCUSED row's drawer", + "oracle": "dom", + "verify": "after a screenshot confirms the list rendered: the focused row carries the ring classes; Enter's drawer field-matches GET /:id for the row the ring was on — identity by API read, never by highlight", + "evidence": "screenshots of the moving ring + the drawer/API identity pair" + }, + { + "clause": "a and r on a quick-decidable row open the SAME confirm dialog the row buttons use, and the confirmed decision executes the SAME server transition: one POST to /api/v1/approvals/requests/:id/(approve|reject), the timeline row lands, the badge and list refresh", + "oracle": "network", + "verify": "the captured POST matches the button path's route and body shape (actor_id resolved from the viewer's identities ∩ pending_approvers); the re-read shows the transition and exactly one new action row; no POST fires before the dialog's confirm", + "evidence": "the network trace + before/after request/actions reads" + }, + { + "clause": "keyboard ownership is guarded on every side: keys are inert while an input/textarea/select/contenteditable is focused, while the drawer sheet or a decide dialog is open (the list handler), and while an alertdialog is up — typing never leaks into navigation or decisions", + "oracle": "dom", + "verify": "each guard exercised: focus state read from the DOM before the keypress, then assert no focus movement / no dialog / no network call followed", + "evidence": "per-guard screenshot + the absence of any decision POST in the trace" + }, + { + "clause": "a/r are gated by quick-decidability: a pending row that declares decisionOutputs never quick-decides from the keyboard (nor from the row buttons) — the drawer's typed-output dialog is the only path, so a required output can never be skipped by a hotkey", + "oracle": "network", + "verify": "a and r on the dynamic-approval stage-1 row produce no dialog and no POST (quickDecidable = isActionable AND no declared outputs, #2829 — a quick approve would hand the flow nothing and the next stage's expression approver would resolve an empty slate)", + "evidence": "the inert keypress capture + the empty network trace" + }, + { + "clause": "with the drawer open, ←/→ walk the filtered list order directly drawer-to-drawer, and x/space toggle selection only on the pending tab", + "oracle": "dom", + "verify": "arrow keys swap the drawer to the adjacent row's request (identity via API read each hop); x on 待我审批 toggles the row into the bulk bar's actionable set, x on 我发起的 does nothing", + "evidence": "drawer-hop identity reads + selection screenshots per tab" + } + ], + "negative": [ + "an a/r that POSTs a decision with NO confirm dialog is a FAIL — the dialog is the misfire guard between a stray keypress and a recorded decision", + "a keyboard decision landing on a different request than the focused row is a FAIL — verify identity by API read, not by which row looks highlighted", + "j/k/a/r firing while the search input (or any dialog) owns the keyboard is a FAIL of the ownership guards", + "a hotkey quick-decide succeeding on a row with declared decisionOutputs is a FAIL — it would skip a REQUIRED output the drawer dialog exists to collect" + ], + "traps": ["automation-input", "hydration-race"], + "source": [ + "objectui apps/console/src/pages/system/ApprovalsInboxPage.tsx:1272-1311 (the list keyboard effect — j/k/arrows/Enter/x/space/a/r; ownership guards :1281-1285: selectedId/dialog yield, INPUT/TEXTAREA/SELECT/contentEditable, [role=alertdialog]), :1314-1332 (drawer ←/→ walk), :1157-1158 (quickDecidable = isActionable AND no declared decision outputs, #2829), :1231-1270 (inlineApprove/inlineReject → approvalsApi.approve/reject — the same routes as the buttons), :1610 (the focus ring classes), :1758 (the hint bar copy), :14-23 (the file's own keyboard contract docblock)", + "examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts (the stock decisionOutputs row the gating contrast rides)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "initial — the inbox's keyboard surface (documented in the page's own header and advertised by its hint bar) had no checklist coverage; the load-bearing halves are that r/a land the same server transition as the buttons and that quick-decidability gates hotkeys off rows whose decisions require typed outputs", "ref": "#sweep-2026-08-30" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index b8508495f4..90a6baef72 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -1573,7 +1573,7 @@ "title": "The Setup packaged-automation page: reachable from Setup nav, lists packaged flows + packaged actions, toggle persists, clone on flow rows only, no lineage surface, server refusals verbatim in role=alert", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "browser", "personas": [ @@ -1588,14 +1588,14 @@ "the stock subflow pair showcase_task_done_notify_owner → showcase_notify_owner (drives the 409 refusal-rendering clause)" ], "knownGaps": [ - "REPO OWNERSHIP (ADR-0054): the PAGE — markup, testids, dialogs, refusal rendering — is objectui's (packages/app-shell/src/views/setup/PackagedAutomationPage.tsx + PackagedActionsSection.tsx, registered as ref 'automation:packaged' in services/builtinComponents.tsx:64-69); the Setup NAV METADATA that should reach it is objectstack's (packages/platform-objects/src/apps/setup-nav.contributions.ts). A locator or copy fix goes to objectui; the missing nav entry goes HERE. Conflating the two repos is how the nav gap survived both repos' tests", + "REPO OWNERSHIP (ADR-0054): the PAGE — markup, testids, dialogs, refusal rendering — is objectui's (packages/app-shell/src/views/setup/PackagedAutomationPage.tsx + PackagedActionsSection.tsx, registered as ref 'automation:packaged' in services/builtinComponents.tsx:64-69); the Setup NAV METADATA that reaches it is objectstack's (packages/platform-objects/src/apps/setup-nav.contributions.ts:63 — nav_packaged_automation, landed by #12457). A locator or copy fix goes to objectui; a nav-entry regression goes HERE. Conflating the two repos is how the original nav gap survived both repos' tests — each side now pins its own half (framework: setup-packaged-automation-nav.test.ts; objectui: PackagedAutomationPage.navContribution.test.tsx), and only the live sidebar click observes the seam between them", "AUTOMATION IS PINNED IN THE objectui REPO — `automated.ref` names objectui component tests exclusively, so from this checkout the item is neither runnable nor pin-evidenced; and those tests are the MOCKED half only (stubbed fetch): they prove the page against faked responses and structurally cannot see the missing framework nav entry, a live server's refusal bodies, or persistence. Full protocol: RUNNER.md, the objectui-pinned-automation standing fact" ] }, "steps": [ "boot showcase isolated (dogfood §0, OS_PORT exported); build/serve the pinned console; sign in as the dev admin", "nav probe: GET /api/v1/meta/app?id=setup and search the served navigation for a type:'component' item with componentRef 'automation:packaged'; then look for the entry in the rendered Setup sidebar and click it", - "regardless of the nav verdict, reach the page (typed URL /apps/setup/component/automation/packaged if the sidebar has no entry — recording that the typed URL was needed) and screenshot it settled", + "regardless of the nav verdict, reach the page and screenshot it settled (only if the sidebar entry is MISSING — a regression of #12457 — fall back to the typed URL /apps/setup/component/automation/packaged, recording that the typed URL was needed, so the rest of the item is still scored)", "inventory: compare the flows table rows against GET /api/v1/meta/flow (which items carry a real _packageId and _provenance != 'org') and the actions section against its own packaged-actions read; confirm every switch reads On", "toggle: flip showcase_urgent_task_alert OFF; reload the page fully; record the switch state and the sys_metadata_activation row; flip it back ON", "clone dialog: open Clone on a flow row — try submitting with name empty, label empty, then name 'Bad-Name!' + a label; record what renders; then a legal clone and the post-clone notice; DELETE the clone via the API afterwards", @@ -1607,7 +1607,7 @@ { "clause": "the page is REACHABLE FROM SETUP NAVIGATION: the served setup app metadata contains a type:'component' nav item with componentRef 'automation:packaged', and clicking the sidebar entry lands on the page", "oracle": "network", - "verify": "the GET /api/v1/meta/app?id=setup body carries the item AND the sidebar click navigates to it. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: the objectui page and registry ref exist, but objectstack's packages/platform-objects/src/apps/setup-nav.contributions.ts contributes NO such nav item (the only component ref it ships is developer:packages, :51) — the page is reachable only by typed URL, and Epic layers L5 (#6301) / L6-UI (#6412) are closed, so this is a defect, not pending work; a red here is that product finding, already tracked by the #12438 sweep. ⛔ A typed-URL landing must NOT tick this clause — objectui's own PackagedAutomationPage.navContribution.test.tsx proves only ref→URL resolution (the mocked half), which is exactly the half that cannot see the missing nav entry", + "verify": "the GET /api/v1/meta/app?id=setup body carries the item — id 'nav_packaged_automation', type 'component', componentRef 'automation:packaged', label 'Packaged Automation', in group_apps beside Packages — AND the sidebar click navigates to the page. The entry is contributed at packages/platform-objects/src/apps/setup-nav.contributions.ts:63 (#12457, fixing the gap the #12438 sweep found: the page had merged in objectui with no framework nav entry naming its ref, reachable only by typed URL). Two deliberate ABSENCES on the entry are part of the contract — no requiresService ('automation') and no requiredPermissions (the sys_metadata_activation ledger works without the automation service, #12419; the write doors gate server-side) — do not 'fix' either. ⛔ A typed-URL landing must still NOT tick this clause: each repo pins only its own half (framework nav metadata: setup-packaged-automation-nav.test.ts; objectui ref→URL resolution: PackagedAutomationPage.navContribution.test.tsx), and the live served-app body + real sidebar click is the only observation that crosses the seam the original defect lived in", "evidence": "the meta/app body (item present or absent) + sidebar screenshot" }, { @@ -1654,7 +1654,7 @@ } ], "negative": [ - "ticking the nav clause off a typed-URL landing (or off the objectui navContribution unit test) is the exact false positive this item exists to block — the page working and the page being findable are different facts, and only the second is red today", + "ticking the nav clause off a typed-URL landing (or off either repo's unit pin alone) is the exact false positive this item exists to block — the page working and the page being findable are different facts, and the second was red for a full epic before #12457 precisely because each repo's tests proved only its own half", "scoring the member's refusal cell while the admin's token is still live in the browser context is an auth-state-leak — the alert would show a 409 (subflow) instead of the 403 (capability), silently proving the wrong clause", "a toggle whose switch flips back On after reload while the API row says active=false (or vice versa) is a FAIL wherever the mismatch lies — the page must derive from the server, and the server must have written the row" ], @@ -1666,18 +1666,18 @@ ], "automated": { "kind": "test", - "ref": "objectui packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx + PackagedActionsSection.test.tsx — the MOCKED component half only (stubbed fetch): rendering, filter, dialog validation, alert wiring; they cannot evidence the nav clause, live refusal bodies, or persistence" + "ref": "objectui packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx + PackagedActionsSection.test.tsx — the MOCKED component half only (stubbed fetch): rendering, filter, dialog validation, alert wiring; they cannot evidence live refusal bodies or persistence. The nav clause's framework half is pinned HERE by packages/platform-objects/src/apps/setup-packaged-automation-nav.test.ts (#12457: entry present, ref/label/group, the two deliberate absences, parses as a NavigationContribution) — runnable from this checkout, but it proves the contribution only; the served body + sidebar click still need the live run" }, "source": [ "docs/adr/0126-packaged-metadata-customization-model.md §7.4 (the Setup surface: on/off + clone, authoring stays in Studio; ⛔ no drift/ancestry surface)", "objectui packages/app-shell/src/views/setup/PackagedAutomationPage.tsx (:327,529 clone-dialog validation; :427,516 role=alert refusal rendering) + PackagedActionsSection.tsx (header: ⛔ no clone on actions) + packagedFlows.ts:59-106 (isPackagedFlowItem + runtime-spine join)", "objectui packages/app-shell/src/services/builtinComponents.tsx:64-69 (registerAppComponent ref 'automation:packaged') + views/ComponentNavView.tsx:26-60 (ref resolution, no gate) + views/setup/PackagedAutomationPage.navContribution.test.tsx (ref→URL resolution — the mocked half of the nav clause)", "objectui packages/core/src/actions/actionErrorDetail.ts:27-35 (error → error.message → message; details[] dropped)", - "packages/platform-objects/src/apps/setup-nav.contributions.ts (NO automation:packaged nav item at af56546 — the expected-fail's ground)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts:52-63 (nav_packaged_automation → componentRef 'automation:packaged', landed by #12457; the entry's comment records why it lives in platform-objects, not service-automation) + setup-packaged-automation-nav.test.ts (the framework-half pin)", "packages/runtime/src/domains/automation.ts:333-335 (the exact enablement 403 sentence)", "packages/platform-objects/src/system/sys-metadata-activation.object.ts:152-158 (reads open)", "docs/adr/0054 (locator stability / UI-testability contract — the repo-ownership split recorded in fixtures)", - "#12438 (sweep; nav gap found from two angles) · Epic #12150 (L5 #6301 / L6-UI #6412 both closed → the nav gap is a defect, not pending work)", + "#12438 (sweep; nav gap found from two angles) → #12457 (the fix: framework nav entry + pin) · Epic #12150 (L5 #6301 / L6-UI #6412)", "cross-ref: automation.packaged-flow-disable-durable and automation.packaged-flow-subflow-disable-refusal (the API contracts whose surfaces this page renders); automation.packaged-flow-clone-contract (the clone door behind the dialog)" ], "history": [ @@ -1686,6 +1686,12 @@ "date": "2026-08-26", "change": "new — the ADR-0126 §7.4 Setup surface shipped in objectui with mocked component tests only; nothing asserted the cross-repo seams where the real risks live: the Setup nav entry (missing at af56546 — authored as an expected-fail reachability clause a typed URL must not satisfy), verbatim server refusals surviving to role=alert against a LIVE server, toggle persistence corroborated by the ledger row, clone-on-flows-only, the no-lineage rule, and the plain-member read posture (recorded, with the maintainer question flagged rather than adjudicated)", "ref": "#12438" + }, + { + "revision": 2, + "date": "2026-08-30", + "change": "inverted the nav clause from expected-fail to a positive assertion: #12457 landed the missing framework nav entry (setup-nav.contributions.ts:63 — nav_packaged_automation → 'automation:packaged', verified at head) with its own pin (setup-packaged-automation-nav.test.ts), so the clause now asserts the entry in the served setup app body + a real sidebar landing, including the entry's two deliberate absences (no requiresService, no requiredPermissions — #12419 / server-side gating) so neither is 'fixed' into a regression. The 'missing nav entry goes HERE' knownGap is rewritten as the two-pins-one-seam ownership note; the typed-URL fallback in steps is re-scoped to regression handling. D16 is fixed — a red here is a NEW regression, not the old finding re-found", + "ref": "#12457" } ] } diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 03b4b37048..d5616a6de3 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1519,6 +1519,103 @@ "ref": "#11421" } ] + }, + { + "id": "cli.dev-automigrate-policy", + "title": "autoMigrate off|safe: a dev boot self-heals ONLY the safe-categorized drift and reports each applied op, `off` (the driver default) and NODE_ENV=production never touch the schema, and a duplicates-blocked tighten is never auto-applied", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a SCRATCH app copy (worktree copy of examples/app-showcase or a scaffolded blank app) whose config declares a `default` sqlite datasource with a file DB under /tmp// — deliberate metadata edits stage the drift, so shared fixtures are never touched", + "a debug-readable boot log for every boot — the safe-apply evidence is the driver's own [schema-drift] lines", + "for the config-declared arms: the datasource's driver `config` is where `autoMigrate` is authored (sqlite.zod.ts:78; the same key on postgres/mysql); for the injected arm: a boot with NO config-declared datasource, whose fallback default datasource gets `autoMigrate: 'safe'` from the CLI only when isDev" + ], + "knownGaps": [ + "the duplicates-BLOCKED leg (ADR-0120 D4 tightenNullSafeOnly recreate: duplicate rows found by the pre-flight probe → op blocked with a row report, old index left in place) needs planted duplicate rows under a legacy platform-wide unique index — stageable only by writing rows directly into the scratch sqlite file before boot. The leg is unit-pinned (sql-driver-unique-tenancy.test.ts 'auto-tightens at boot … when the probe is clean' + its duplicates sibling); a run may cite the pin per RUNNER rule 6 instead of hand-staging, and must record WHICH it did", + "`autoMigrate` is honoured by the native sqlite / postgres / mysql drivers only — the sqlite-wasm driver is constructed without it (the guidance entry on SqliteWasmConfigSchema says writing it there changes nothing), and turso's config declares no such key (standalone-stack.ts:635). Do not stage the wasm/turso arms expecting behavior" + ] + }, + "variants": [ + "off — the driver default when the key is omitted (sql-driver.ts:4728 `autoMigrate ?? 'off'`): drift is WARNED, never applied", + "safe — the loosen-only subset applied at boot (relax NOT NULL, widen varchar, safe index ops, clean-probe tighten), never destructive DDL", + "dev injection — `os dev`/`os serve --dev` pass 'safe' themselves for the FALLBACK default datasource (storage-driver.ts:324) and the telemetry datasource (serve.ts:2598); non-dev serve passes nothing, so the driver default 'off' stands", + "NODE_ENV=production — 'safe' force-ignored with the named warning, schema never auto-altered", + "schemaMode gate — safe is honoured only under schemaMode 'managed' (sql-driver.ts:10620 autoOn = safe && managed); an external-schema datasource is never auto-altered", + "duplicates-blocked — the ADR-0120 D4 tighten goes through the duplicate pre-flight probe: clean → recategorised safe (auto-appliable), duplicates → blocked with a row report (schema-drift.ts:250-259)" + ], + "enumSource": { "file": "packages/spec/src/data/driver/common.zod.ts", "export": "SqlAutoMigrateSchema", "expect": 2 }, + "steps": [ + "stage real drift in the scratch copy: declare a field `required: true`, `os build`, boot once against the file DB so the NOT NULL column lands; stop; flip the field to `required: false`, `os build` — the physical column is now STRICTER than metadata, which is exactly the loosening drift 'safe' exists for", + "the off side first: boot with the datasource's config carrying `autoMigrate: 'off'` (and once more with the key OMITTED — the driver default); capture the [schema-drift] WARNING for the divergence in both boots, then stop and run `os migrate plan` — the drift must still be pending, and a schema dump before/after the two boots must be identical", + "the safe side: boot the same DB with `autoMigrate: 'safe'`; capture the `[schema-drift] auto-reconciled on .` info line(s); stop and run `os migrate plan` — the loosened column must no longer be pending; boot AGAIN and confirm no further reconcile lines (the heal converged)", + "production force-off: re-stage the drift, then boot with NODE_ENV=production and `autoMigrate: 'safe'` still declared; capture the exact warning `[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production — schema is never auto-altered in production. Run 'os migrate' deliberately.` and confirm via schema dump that nothing was altered", + "the injected arm: in a scratch project with NO config-declared datasource, re-stage the drift and boot plain `os dev` — the CLI's fallback default datasource carries autoMigrate:'safe' in dev (storage-driver.ts:324), so the same auto-reconciled line must appear with no authored key anywhere", + "the destructive boundary on the same boots: stage a REMOVED field too (a drop is category destructive) and confirm the safe boot leaves it as a warning + pending plan entry — `os migrate apply --allow-destructive --yes` remains the only door (cross-ref cli.migrate-plan-apply-json)", + "the duplicates-blocked leg: run the unit pin (packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts + sql-driver-schema-drift.test.ts) and cite its output — or hand-stage duplicate rows in the scratch sqlite file and boot with safe, capturing the blocked report; record which route the verdict rests on" + ], + "acceptance": [ + { + "clause": "`safe` applies EXACTLY the safe-categorized diffs at boot and reports each one: every applied op logs `[schema-drift] auto-reconciled on
.` (info), drift is re-detected after the reconcile, and anything remaining is still warned once per divergence — never silently swallowed", + "oracle": "log", + "verify": "the safe boot's log carries one auto-reconciled line per staged loosening; a follow-up `os migrate plan` no longer lists it; a second safe boot reconciles nothing (converged). Mechanism: reconcileAndWarnDrift filters category === 'safe' into applyMigrationEntries({ allowDestructive: false }) (sql-driver.ts:10606-10659)", + "evidence": "the boot log lines + the before/after plan outputs" + }, + { + "clause": "`off` — explicit OR omitted — boots WITHOUT touching the schema: the same drift is warned (`[schema-drift] `) on every boot, two consecutive off boots report identical drift, and the physical schema is byte-identical before and after", + "oracle": "log", + "verify": "paired boot logs show the warning both times with no auto-reconciled line; the schema dumps (or a third `os migrate plan`) match exactly. The default is 'off' at the DRIVER (sql-driver.ts:4728) — only the CLI's dev boots inject 'safe'", + "evidence": "the two boot logs + the schema-dump pair" + }, + { + "clause": "production force-off: with NODE_ENV=production, a declared `autoMigrate: 'safe'` is IGNORED with the exact named warning and the remedy ('Run os migrate deliberately') — the schema is never auto-altered in production, whatever the config says", + "oracle": "log", + "verify": "the production boot log carries the sql-driver.ts:10621-10624 warning verbatim and NO auto-reconciled line; the schema dump is unchanged", + "evidence": "the warning line + the unchanged schema dump" + }, + { + "clause": "the gate composes: safe runs only under schemaMode 'managed' (autoOn = autoMigrate==='safe' && schemaMode==='managed'), and the DESTRUCTIVE category is never auto-applied under any setting — a staged drop stays a warning + pending plan entry on the safe boot, applied only through `os migrate apply --allow-destructive`", + "oracle": "log", + "verify": "the safe boot's log shows the drop warned, not applied; `os migrate plan` still lists it; the applyMigrationEntries call is pinned allowDestructive:false (sql-driver.ts:10629)", + "evidence": "the safe-boot log + the still-pending plan" + }, + { + "clause": "the ADR-0120 D4 tighten is data-dependent and fails CLOSED: the bare-organization-column → NULL-safe unique recreate goes through the duplicate pre-flight probe — clean probe ⇒ recategorised safe and auto-applied at boot; duplicates found ⇒ BLOCKED with a row report and the old index left in place, never auto-applied under either autoMigrate value", + "oracle": "test", + "verify": "run the pinning suites (sql-driver-unique-tenancy.test.ts 'auto-tightens at boot under autoMigrate: safe when the probe is clean' and the duplicates-blocked cases; sql-driver-schema-drift.test.ts) and cite their output — or the hand-staged live capture, naming which the verdict rests on", + "evidence": "the test output (or the staged blocked report)" + }, + { + "clause": "the CLI injects 'safe' ONLY in dev, and only where it says: the fallback default datasource (no config-declared driver) and the telemetry datasource carry autoMigrate:'safe' when isDev and nothing otherwise — so a plain `os dev` self-heals with no authored key, while a production `os serve` boot leaves the driver at its own 'off' default", + "oracle": "log", + "verify": "the keyless dev boot shows the auto-reconciled line (injection worked); a non-dev serve boot over the same staged drift shows warnings only (storage-driver.ts:324 `isDev ? { autoMigrate: 'safe' } : {}`; serve.ts:2598 telemetry `isDev ? 'safe' : undefined`)", + "evidence": "the paired keyless boot logs" + } + ], + "negative": [ + "a `safe` boot applying anything the drift detector categorised destructive (a drop, a narrowing) is THE fail the loosen-only contract exists for — reproduce twice, file", + "an `off` (or omitted, or production) boot whose schema dump changes across the boot is a FAIL even if no [schema-drift] line printed — the dump pair is the authority, not the log's silence", + "a duplicates-carrying tighten being auto-applied (losing rows to a unique constraint, or failing the boot) instead of blocked-with-report is a FAIL against the ADR-0120 D4 probe contract", + "an authored `autoMigrate` on a sqlite-wasm datasource appearing to DO something is a FAIL of the declared boundary (the wasm driver is constructed without it — the guidance entry says writing it changes nothing); conversely, do not file the wasm no-op as a missing capability" + ], + "traps": ["stale-dist", "destructive-in-place"], + "automated": { "kind": "unit", "ref": "packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts" }, + "source": [ + "packages/spec/src/data/driver/common.zod.ts:22-32 (SqlAutoMigrateSchema z.enum(['off','safe']) — 'Dev-only, loosen-only schema self-heal (#2186) … force-disabled under NODE_ENV=production') + sqlite.zod.ts:78 (the authored key; :107-111 the wasm not-honoured guidance)", + "packages/drivers/driver-sql/src/sql-driver.ts:4021-4030 (SqlDriverConfig.autoMigrate docblock: non-destructive alters only), :4728 (default 'off'), :10601-10659 (reconcileAndWarnDrift — the managed+safe gate :10620, the production ignore warning :10621-10624 verbatim, safe-only applyMigrationEntries allowDestructive:false :10626-10629, per-op auto-reconciled info line :10641, post-reconcile re-detect, warn-once loop)", + "packages/drivers/driver-sql/src/schema-drift.ts:250-259 (tightenNullSafeOnly: clean probe → safe, duplicates → blocked with a row report, old index left in place; ADR-0120 D4)", + "packages/cli/src/utils/storage-driver.ts:324 (the ONE dev injection point for the fallback default datasource: isDev ? { autoMigrate: 'safe' } : {}) + packages/cli/src/commands/serve.ts:2596-2600 (telemetry datasource: isDev ? 'safe' : undefined)", + "packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts + sql-driver-unique-tenancy.test.ts:641-668 + sql-driver-index-drift.test.ts:112-113,322-342 (the pins: off-warns / safe-applies / clean-probe tighten / duplicates block)", + "sibling items: cli.dev-boot-contract (the boot this rides), cli.migrate-plan-apply-json (the deliberate apply path — the ONLY door for destructive diffs; cross-referenced, not re-proven)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, angle 2): the autoMigrate off|safe policy had no coverage although it is the one path that mutates a dev database at boot. Grounded verify-first, correcting the register's sketch on two points: (1) 'safe' is the LOOSEN-only subset (relax NOT NULL, widen varchar, index ops, clean-probe tighten) — not 'additive' diffs; (2) the register's sql-driver.ts:4030/4652-4654 cites the config/field declarations, but the enforcement lives in reconcileAndWarnDrift (:10601-10659) with the managed-mode gate, the production force-off warning, and allowDestructive:false — all now pinned as clauses. The dev-injection arm (storage-driver.ts:324 / serve.ts:2598, 'safe' only when isDev) was found while grounding and added as its own clause so a keyless dev boot's self-heal is not misread as a driver default. enumSource pins SqlAutoMigrateSchema expect 2", "ref": "#sweep-2026-08-30" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/dashboards.json b/docs/qa/platform-checklist/areas/dashboards.json index c9a39f82da..851eb6120d 100644 --- a/docs/qa/platform-checklist/areas/dashboards.json +++ b/docs/qa/platform-checklist/areas/dashboards.json @@ -1048,6 +1048,111 @@ "ref": "claude/platform-test-checklist-ocwugl" } ] + }, + { + "id": "dashboards.report-schedule-dispatch-delivery", + "title": "Scheduled report dispatch: the dispatcher ticks on a stock boot, a due schedule runs under the OWNER's authority or FAILS CLOSED with the refusal recorded on the row — never an RLS-bypassed run — and the delivery/bookkeeping contract (format fork, subject template, next-run advance) holds", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "member (report + schedule owner — a fresh runtime sign-up, NOT the admin: the fail-closed clause is about a non-privileged owner's authority)", + "admin (reads the server log)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a runtime-signed-up member owning a saved report + a schedule (showcase seeds no sys_saved_report/sys_report_schedule rows — create both in-run, the same in-run pattern as dashboards.saved-report-ownership)", + "a schedule due within the session: intervalMinutes 1 (or cronExpression '* * * * *') so first fire lands ≤ interval + one dispatcher tick (~2 min on the 60s default)", + "the reports capability on the boot (CLI serve auto-loads @objectstack/plugin-reports — the dispatcher registers on kernel:ready) and the boot log kept for the whole run" + ], + "knownGaps": [ + "THE POSITIVE DELIVERY LEGS ARE NOT LIVE-OBSERVABLE AT HEAD, BY THE PLATFORM'S OWN CHOICE: reports-plugin.ts constructs ReportService with resolveOwnerContext: undefined ('No owner-context resolver is wired yet — that is the reports-surface consumer of ADR-0073's user-less identity resolution (M2) — so until it lands, scheduled runs FAIL CLOSED'). Every scheduled dispatch on a real boot therefore takes the fail-closed arm — which makes THAT arm the live assertion, and the owner-scoped run / format fork / subject / advance-to-'ok' clauses scoreable only via the unit pin (report-service.test.ts injects a resolver). When ADR-0073 M2 wires the resolver, flip those clauses to live legs and revise this item", + "NO DOOR DRIVES dispatchDue ON DEMAND: the dispatcher is an internal tick (job service or setInterval, min 5s, default 60s; no REST/CLI trigger) — the runner waits for a tick rather than firing one; verdicts on schedule-row state must re-read after the tick window (eventual-consistency)", + "MAIL ON A STOCK BOOT IS THE LOG TRANSPORT: plugin-email defaults to provider 'log' ('no transport configured — using LogTransport (mail will NOT be sent)'), so even with a resolver wired, delivery evidence would be the transport's log line, never a mailbox; the reports-plugin :103 'no email service' warn arm fires only on boots without the email capability — record which arm the boot log shows" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); read the boot log for the dispatcher registration line — 'dispatcher registered with job service' or 'dispatcher registered (setInterval fallback)', with its intervalMs — and for the email arm (LogTransport line, or the 'no email service — schedules will fire without delivery' warn)", + "as a fresh member: POST /api/v1/reports (a report over an object the member can read, e.g. showcase_task) then POST /api/v1/reports/:id/schedule with { recipients: [''], intervalMinutes: 1, format: 'csv', subjectTemplate: '{{name}}: {{rows}} on {{date}}' } — record the receipt's next_run_at (first fire = create time + interval; an invalid cronExpression is refused eagerly at create, not left to fall back silently at 3am)", + "wait past next_run_at + one dispatcher tick, then as the member GET /api/v1/reports/:id/schedules and read the row's last_status / last_error / last_sent_at / next_run_at", + "capture the server log lines for the tick (ReportService.dispatchDue warn/error lines)", + "prove the negative space of the fail-closed arm: confirm NO mail-send line landed for this schedule in the log window and the member's report data was not emailed anywhere", + "unit half: run packages/plugins/plugin-reports/src/report-service.test.ts and cite the dispatch describe blocks (owner-scoped run, fails-closed-without-resolver, csv attachment, subject template) for the clauses the wired-off resolver keeps off the live path", + "cross-owner/anonymous surfaces of /reports* are dashboards.saved-report-ownership — do not re-drive them here" + ], + "acceptance": [ + { + "clause": "the dispatcher is alive on a stock boot: the boot log shows the reports dispatcher registered (job-service or setInterval arm, interval recorded), and after the due window the schedule row's bookkeeping fields CHANGED — the tick provably visited the row", + "oracle": "log", + "verify": "the registration line + the row's last_status/last_error transitioning from empty to a recorded outcome within ~(interval + tick) of next_run_at; re-read after the window before concluding anything (eventual-consistency)", + "evidence": "boot-log excerpt + before/after schedule-row reads" + }, + { + "clause": "a due schedule NEVER runs RLS-bypassed: with no owner-context resolver wired (head posture), the dispatch FAILS CLOSED — the row reads last_status 'failed' with last_error naming the refusal verbatim (\"owner '' context unavailable — refusing to run scheduled report with RLS bypassed (#2849/#2980)\") and no mail-send line lands for the schedule. This is the guard against the #2980 regression class where a member's scheduled report emailed the target object's ENTIRE table under system authority", + "oracle": "api", + "verify": "the owner's GET /reports/:id/schedules row: last_status === 'failed', last_error carries the #2849/#2980 refusal naming the owner id; the log shows the dispatchDue failure/warn for it and NO email transport send for relatedId = the schedule id. A last_status 'ok' here at head is the FAIL — it would mean the run executed with no resolvable owner context, i.e. elevated", + "evidence": "the schedule-row read + the log window" + }, + { + "clause": "owner-authority scoping (unit-pinned until ADR-0073 M2): with a resolver injected, dispatchDue resolves the REPORT's owner_id to a real RLS-bearing context and executes under IT — a member-owned schedule over a private-OWD object reads only the member's rows, and an unresolvable/disabled owner takes the fail-closed arm, never a system fallback", + "oracle": "test", + "verify": "run report-service.test.ts and cite the #2980 authorization block: the resolver receives report.owner_id, executeReport gets that context, and 'dispatchDue: fails closed (no RLS bypass) when no owner resolver is configured' passes", + "evidence": "the test output naming the passing cases" + }, + { + "clause": "delivery contract (unit-pinned until ADR-0073 M2): format 'csv' sends the rendered body as a text/csv ATTACHMENT whose filename keeps unicode letters (a 周报 schedule name survives — only filesystem-hostile characters are stripped, never every non-ASCII char to '__') while 'html_table' sends the inline HTML table; the subject renders the {{name}}/{{date}}/{{rows}} template; both sends carry relatedObject sys_report_schedule + the schedule id", + "oracle": "test", + "verify": "report-service.test.ts: 'dispatchDue: csv schedule attaches a file' (filename /\\.csv$/, contentType text/csv) and the subject-template case ('Open leads: 2 on 2026-01-15'); the CJK-safe replace is report-service.ts's own \\p{L}\\p{N} class", + "evidence": "the test output" + }, + { + "clause": "bookkeeping is per-arm and durable: success advances next_run_at (cron_expression wins over interval_minutes, evaluated in the schedule's timezone; invalid/no-future cron falls back to interval with a warn) and stamps last_status 'ok' + clears last_error; a thrown dispatch marks 'failed' with the message truncated to 500 chars; an orphaned schedule (report row gone) marks 'skipped' naming the missing report — each arm writes THROUGH markSchedule/advanceSchedule so the row, not the log, is the durable record", + "oracle": "test", + "verify": "the live run evidences the failed arm's row write (clause 2); the ok/skipped arms and the cron-vs-interval advance are cited from report-service.test.ts + nextRunAt/advanceSchedule/markSchedule source (the skipped arm is not honestly reachable live — deleteReport cascades its schedules, so an orphan row is not creatable through the owner API)", + "evidence": "test output + the live failed-arm row" + }, + { + "clause": "absent email service degrades loudly, never silently: on a boot without the email capability the plugin warns at start ('no email service — schedules will fire without delivery') and each fired dispatch warns ('schedule fired but mail not sent') — and the run record notes that last_status 'ok' means DISPATCHED, not delivered: delivery truth is the transport line, the row's vocabulary never claims a mailbox", + "oracle": "log", + "verify": "record which email arm the boot log shows (stock = LogTransport registered, so this clause's warn arm needs a no-email boot variant — record as not-exercised on stock rather than ticking it); the :103 and dispatch warn lines are the oracle when the arm is driven", + "evidence": "boot-log excerpt naming the arm" + } + ], + "negative": [ + "a scheduled dispatch reaching last_status 'ok' at head (resolver unwired) is the FAIL this item exists for — it means the run executed without an owner context, i.e. the #2980 silent-bypass regressed; the fail-closed refusal is the CORRECT behavior and must not be filed as a defect (it is the platform's recorded posture pending ADR-0073 M2)", + "a fail-closed dispatch that leaves last_error empty (or a generic message that does not name the owner/refusal) is a FAIL of the bookkeeping half — the row is the operator's only durable signal that their schedule is not delivering", + "a dead dispatcher — no registration line, or a due row still untouched long past next_run_at + tick — is a FAIL of the loop itself, distinct from any per-schedule outcome", + "SCOPE: cross-owner/anonymous denial on /reports* (including the schedule routes) is dashboards.saved-report-ownership; the export-permission axis at schedule-create time (#3544/#3710 assertExportAllowed) is adjacent and only its eager-refusal shape is relied on in step 2 — cite, don't re-prove" + ], + "traps": [ + "eventual-consistency", + "clock-skew", + "wrong-persona" + ], + "automated": { + "kind": "test", + "ref": "packages/plugins/plugin-reports/src/report-service.test.ts — the dispatch describes: owner-scoped execution + fails-closed-without-resolver (#2980), csv attachment, subject template, cron/interval advance; the LIVE halves (dispatcher registration, the fail-closed row write on a real boot, the log arms) still need the run" + }, + "source": [ + "packages/plugins/plugin-reports/src/report-service.ts:698-800 (dispatchDue: due filter on next_run_at; owner fail-closed :728-744 with the verbatim #2849/#2980 refusal; format fork + CJK-safe filename :758-783; renderSubject :191-194/:752-756; skipped arm :712-718), :812-828 (nextRunAt — cron wins, schedule timezone, warned fallback), :830-850 (advanceSchedule 'ok'/markSchedule), :588-637 (scheduleReport — first next_run_at, eager cron validation, recipients required)", + "packages/plugins/plugin-reports/src/reports-plugin.ts:97-104 (optional email + the :103 no-email warn), :125-138 (resolveOwnerContext: undefined — the recorded fail-closed posture pending ADR-0073 M2), :141-174 (dispatcher: job-service arm 'reports.dispatch' or setInterval fallback, min 5s / default 60s)", + "packages/plugins/plugin-reports/src/report-service.test.ts (the unit pins cited by clauses 3-5)", + "packages/cli/src/commands/serve.ts (CAPABILITY_PROVIDERS.reports → @objectstack/plugin-reports — the dispatcher runs on stock boots)", + "packages/plugins/plugin-email/src/email-plugin.ts:379-381 (stock provider 'log' → LogTransport, 'mail will NOT be sent' — why delivery evidence is a log line)", + "docs/plans/release-15.1-test-plan.md §A10 (reports IDOR / 定时报表 RLS — #2980/#2981/#2975; the ownership half was imported as dashboards.saved-report-ownership, this item is the 定时报表-RLS dispatch leg that never was)", + "cross-ref: dashboards.saved-report-ownership (the /reports* owner-isolation surfaces, including schedule routes #6683)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new item (2026-08-30 sweep, angle 5): the dispatch loop was the un-imported leg of release-15.1 §A10 (定时报表 RLS) — nothing asserted the dispatcher runs on a real boot or that a due schedule cannot run RLS-bypassed. Grounded against source with one major correction to the register hypothesis: the register's positive framing ('a due schedule fires under the OWNER's RLS … mail lands at dev transport') is unreachable live at head, because reports-plugin.ts wires resolveOwnerContext: undefined (fail-closed pending ADR-0073 M2) and stock mail is the LogTransport — so the LIVE assertion is the fail-closed row write with the verbatim #2849/#2980 refusal (an 'ok' at head is the FAIL), and the owner-scoped/delivery/bookkeeping clauses are pinned at the unit layer with an explicit flip-to-live tripwire for when the resolver lands. Also recorded: no on-demand door drives dispatchDue (tick-wait protocol), and the skipped arm is unreachable through the owner API (deleteReport cascades schedules)", + "ref": "#sweep-2026-08-30" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 2b5763e011..9cac127d2d 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -997,32 +997,40 @@ "title": "OAuth provider: register an app (secret shown once), run the authorization-code consent loop — approve mints tokens + a consent record, deny mints none", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "mixed", "personas": ["an org admin registering the OAuth client", "the resource-owner user granting/denying consent"], "fixtures": { "app": "showcase", "requires": [ - "the better-auth oidcProvider plugin configured (the oauth2/* routes are gated: auth-route-ledger 'requires: oidcProvider') — stock showcase ships no configured OAuth provider flow, so this item is blocked(fixture) until one is provisioned", - "a registered client + a redirect URI to complete the authorization-code round trip" + "NO bespoke provider fixture: the embedded authorization server is ON BY DEFAULT on a stock boot. resolveOidcProviderEnabled = OS_OIDC_PROVIDER_ENABLED env ?? config oidcProvider ?? readMcpServerEnabledEnv() (auth-manager.ts:302-304), and the MCP surface defaults ON — unset OS_MCP_SERVER_ENABLED means enabled (packages/types/src/env.ts:292-298) — so the oauth2/{authorize,token,consent,create-client,…} family mounts on every stock boot (auth-route-ledger.ts:173-178, requires: oidcProvider). RUNTIME CAVEAT: before scoring anything, confirm neither OS_MCP_SERVER_ENABLED nor OS_OIDC_PROVIDER_ENABLED is set falsy on this boot — an operator opt-out flips the whole surface off (that boot exercises the off-side clause instead)", + "a registered client + redirect URI — runtime-creatable with NO external IdP, through any of three doors: the console's Setup → OAuth Applications create action (nav_oauth_apps → sys_oauth_application create → POST /api/v1/auth/sys-oauth-application/register, the session-required wrapper that splits the redirect-URL textarea into redirect_uris — auth-route-ledger.ts:241), the SDK POST /api/v1/auth/oauth2/create-client (oauth.applications.register), or RFC 7591 DCR at POST /api/v1/auth/oauth2/register (resolveDcrEnabled follows the same MCP default, auth-manager.ts:313-320)" ], "knownGaps": [ - "sys_oauth_consent is apiEnabled:false (apiMethods []) — the consent ROW is not readable over the data API; verify consent via the auth surface GET /api/v1/auth/oauth2/get-consents (the row's presence implies consent for the listed scopes; the old consent_given boolean was removed)" + "sys_oauth_consent is apiEnabled:false (apiMethods []) — the consent ROW is not readable over the data API; verify consent via the auth surface GET /api/v1/auth/oauth2/get-consents (the row's presence implies consent for the listed scopes; the old consent_given boolean was removed)", + "revision 1 conflated this item's fixture with an EXTERNAL IdP: this item tests the platform AS the OAuth/OIDC provider (its own authorization server, on by default); it never needed an external identity provider. identity-auth.linked-accounts-social and identity-auth.sso-enforced-first-paint genuinely DO need a configured external IdP and stay blocked — do not carry their blocker back here" ] }, - "blocked": { "by": "fixture", "ref": "no stock showcase oidcProvider flow — needs a configured OAuth provider + client, same fixture class as identity-auth.sso-enforced-first-paint / linked-accounts-social" }, "steps": [ - "as admin, register an OAuth client: POST /api/v1/auth/oauth2/create-client (client oauth.applications.register, requires oidcProvider); capture the response and the client_secret — revealed ONCE at registration", + "wiring precheck: on the stock boot, confirm the embedded AS is up — GET /.well-known/oauth-authorization-server answers 200 with authorization/token endpoints under /api/v1/auth/oauth2/*, and the boot env sets neither OS_MCP_SERVER_ENABLED nor OS_OIDC_PROVIDER_ENABLED falsy; record both facts before the first oauth2 call", + "as admin, register an OAuth client with NO external IdP: either the console Setup → OAuth Applications create action (posts /api/v1/auth/sys-oauth-application/register) or POST /api/v1/auth/oauth2/create-client (client oauth.applications.register); capture the response and the client_secret — revealed ONCE at registration", "re-read the client via GET /api/v1/auth/oauth2/get-client and confirm the secret is NOT returned again (only client_id / public metadata)", - "begin the authorization-code flow: GET /api/v1/auth/oauth2/authorize with the client_id, redirect_uri, scope and state; as the resource owner, land on the consent page and screenshot the requested-scopes list", + "begin the authorization-code flow: GET /api/v1/auth/oauth2/authorize with the client_id, redirect_uri, scope and state; as the resource owner, land on the consent page — the console's /oauth/consent route (auth-manager.ts:3068 consentPage → OAuthConsentPage, objectui apps/console/src/App.tsx:180) — and screenshot the requested-scopes list", "APPROVE: POST /api/v1/auth/oauth2/consent (oauth.consent) accept; follow the redirect, exchange the code at POST /api/v1/auth/oauth2/token, and capture the issued access/refresh tokens", "confirm a consent record now exists: GET /api/v1/auth/oauth2/get-consents shows a consent for this client covering the approved scopes (sys_oauth_consent row — not data-API readable)", "run the flow again for the SAME client+scopes and confirm the consent screen is SKIPPED (the recorded consent short-circuits it)", "DENY path: start a fresh authorize with an added scope (forcing consent), deny it, and confirm NO tokens are issued and NO new consent is recorded", - "mine-view scoping: confirm the consent surface shows the CALLER's consents only — another user cannot see this user's oauth2 consents" + "mine-view scoping: confirm the consent surface shows the CALLER's consents only — another user cannot see this user's oauth2 consents", + "off-side of the wiring gate (separate boot, or fold into another run): boot with OS_OIDC_PROVIDER_ENABLED=false (or OS_MCP_SERVER_ENABLED=false and no explicit oidcProvider config) and confirm the oauth2 surface is genuinely dark — the authorize/create-client calls refuse or 404, not half-mounted" ], "acceptance": [ + { + "clause": "the embedded authorization server is mounted on the stock boot with nothing configured — the default-ON chain (env unset → config unset → MCP default true) holds live, and the discovery document advertises the mounted oauth2 endpoints", + "oracle": "api", + "verify": "with neither OS_OIDC_PROVIDER_ENABLED nor OS_MCP_SERVER_ENABLED set, GET /.well-known/oauth-authorization-server returns 200 and its authorization/token endpoints answer under /api/v1/auth/oauth2/* (resolveOidcProviderEnabled, auth-manager.ts:302-304; isMcpServerEnabled default true, packages/types/src/env.ts:292-298); on the explicitly-disabled boot the same calls refuse — both sides captured", + "evidence": "the stock-boot discovery read + an oauth2 route probe on each side of the switch" + }, { "clause": "the client_secret is revealed exactly once at registration: create-client returns it, and get-client afterwards returns only public metadata (no secret)", "oracle": "api", @@ -1067,12 +1075,16 @@ ], "traps": ["dispatcher-vs-hono-route", "wrong-persona", "hydration-race"], "source": [ - "packages/plugins/plugin-auth/src/auth-route-ledger.ts (oauth-provider family, requires oidcProvider: oauth2/create-client=oauth.applications.register, get-client, consent=oauth.consent, get-consents, oauth2/authorize, oauth2/token)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:173-178 (oauth-provider family, requires oidcProvider: oauth2/create-client=oauth.applications.register, get-client, consent=oauth.consent, get-consents) + :241 (POST /api/v1/auth/sys-oauth-application/register — the console's session-required registration wrapper) + BETTER_AUTH_MOUNTED_SURFACE rows for oauth2/authorize, oauth2/token, oauth2/register (DCR)", + "packages/plugins/plugin-auth/src/auth-manager.ts:302-304 (resolveOidcProviderEnabled: OS_OIDC_PROVIDER_ENABLED ?? config.oidcProvider ?? readMcpServerEnabledEnv — the default-ON chain, #2698) + :313-320 (resolveDcrEnabled follows the same MCP default) + :3049-3070 (oauthProvider plugin registration; consentPage = console /oauth/consent, loginPage = console /login)", + "packages/types/src/env.ts:292-298 (isMcpServerEnabled — unset means TRUE; explicit false/0/off/no opts out)", + "objectui apps/console/src/App.tsx:180 (/oauth/consent → OAuthConsentPage) + apps/console/src/pages/auth/OAuthConsentPage.tsx", "packages/platform-objects/src/identity/sys-oauth-consent.object.ts (row implies consent for listed scopes — consent_given removed; apiEnabled:false so verify via get-consents, not the data API)", "packages/platform-objects/src/identity/sys-oauth-application.object.ts + setup-nav.contributions.ts (nav_oauth_apps → Setup OAuth Applications)" ], "history": [ - { "revision": 1, "date": "2026-08-08", "change": "new item: OAuth client registration (secret once) + authorization-code consent loop (approve mints tokens + consent record, deny mints none, recorded consent short-circuits), mine-view scoped; blocked(fixture) pending a configured oidcProvider flow (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-08", "change": "new item: OAuth client registration (secret once) + authorization-code consent loop (approve mints tokens + consent record, deny mints none, recorded consent short-circuits), mine-view scoped; blocked(fixture) pending a configured oidcProvider flow (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-30", "change": "UNBLOCKED — the blocked(fixture) claim was stale, verified against source: the embedded authorization server is ON BY DEFAULT (resolveOidcProviderEnabled falls through to the MCP surface default, which is TRUE when OS_MCP_SERVER_ENABLED is unset), so oauth2/{authorize,token,consent,create-client} mount on every stock boot with consent rendered at the console's /oauth/consent, and a client registers with no external IdP (console create action → sys-oauth-application/register, SDK create-client, or DCR). Revision 1 had conflated platform-as-provider (this item) with an external IdP (what linked-accounts-social needs — that one stays blocked). Removed blocked, added the wiring precheck + a both-sides default-ON/off-switch clause, recorded the runtime caveat (confirm OS_MCP_SERVER_ENABLED / OS_OIDC_PROVIDER_ENABLED are not set falsy)", "ref": "#sweep-2026-08-30" } ] }, { @@ -1580,6 +1592,367 @@ "history": [ { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: disable had zero coverage. Encodes full teardown (row/flag/challenge/stale codes), the server-side password gate with negative-first ordering, and the mfa_required re-gate clause worded against the ACTUAL source machinery (lazy never-cleared grace stamp, computeAuthGate) rather than an assumed hard block", "ref": "claude/new-session-0pv25p" } ] + }, + { + "id": "identity-auth.first-run-owner-bootstrap", + "title": "Zero-user first-run: the /setup owner wizard serves even under OS_DISABLE_SIGNUP, creates the owner + names the personal org through a full-document exit, and every bootstrap bypass closes the moment an owner exists", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["anonymous visitor on a zero-user boot (the would-be owner)", "the created owner (post-bootstrap)", "a second anonymous visitor (post-bootstrap forger)"], + "fixtures": { + "app": "any", + "requires": [ + "a ZERO-USER boot — no stock boot provides one: `objectstack dev` seeds a loginable admin BY DEFAULT (seed-admin flag default ON, packages/cli/src/commands/dev.ts:141-142, resolved `flags['seed-admin'] ?? true` at :339), which makes hasOwner true from the first request. The recipe is one flag on a fresh DB: `objectstack dev --no-seed-admin -p -d file:/tmp//bootstrap.db` (a fresh DB file, not a reused one)", + "for the bypass leg, the SAME zero-user boot with OS_DISABLE_SIGNUP=true exported before boot", + "a second stock boot (default flags) for the closed-door contrast leg" + ], + "knownGaps": [ + "not runnable on the stock seeded boot — the --no-seed-admin recipe above IS the fixture; if the environment cannot boot its own server, the wizard legs are blocked(fixture)", + "OBSERVE-AND-RECORD, not a defect claim: the two bootstrap doors count DIFFERENT populations. GET /auth/bootstrap-status answers hasOwner from a bare `dataEngine.count('sys_user', {})` — any row (auth-plugin.ts:1942-1945) — while the sign-up bypass and the audience gate ask isBootstrapCreation, which counts HUMANS (isHumanUserRow excludes usr_system / role 'system'; audience-posture.ts:317-323, auth-manager.ts:3785-3800, [#11767]). On a fresh DB they agree; on a legacy DB carrying only the usr_system service row, bootstrap-status would report hasOwner:true (wizard hidden) while the sign-up gate still treats the creation as bootstrap. If a run meets that divergence, record both reads and file it on the anchor card rather than scoring either door alone" + ] + }, + "steps": [ + "boot zero-user with OS_DISABLE_SIGNUP=true exported and --no-seed-admin on a fresh DB; wait for the serving line (first-boot compile/seed work reads as downtime — trap)", + "as anonymous, GET /api/v1/auth/bootstrap-status — public, no credentials — and capture {hasOwner:false} (the route is exempted from the plugin's auth wall alongside /config, auth-plugin.ts:1895; ledgered GET /api/v1/auth/bootstrap-status = auth.bootstrapStatus, auth-route-ledger.ts:179)", + "open /setup in a fresh browser context: the first-run wizard renders (SetupRoute mode 'first-run'; the verdict comes from useSetupEntryMode over the same bootstrap-status probe, gated on being unauthenticated — objectui#2794)", + "fill owner name/email/password AND an organization name — run at least once with a CJK-or-emoji-only org name (slugify('') guard: the DISPLAY rename must still apply, SetupPage keeps the slug) — and submit", + "capture the network: the wizard drives POST /api/v1/auth/sign-up/email and it SUCCEEDS despite OS_DISABLE_SIGNUP=true — the before-hook flips disableSignUp off for exactly this request when isBootstrapCreation() (auth-manager.ts:1889-1902; comment :1485-1492), and the audience gate's isBootstrap arm admits it under the default invite_only posture (audience-posture.ts:383-385)", + "capture the exit: a FULL-DOCUMENT navigation (window.location.assign), never a SPA route change — the landing console renders the owner's world, not the anonymous-era empty app list (the objectui#4181 hazard, SetupPage.tsx:31-56)", + "verify server state: GET /api/v1/auth/get-session identifies the owner; GET /api/v1/auth/organization/list shows the auto-provisioned org carrying the TYPED display name (the wizard renames the bootstrap org — it must NOT create a second one; single-org mode forbids createOrganization)", + "bypass closed, door 1: as a second anonymous visitor on the same OS_DISABLE_SIGNUP=true boot, POST /api/v1/auth/sign-up/email → server-side refusal (a human user now exists; the toggle is enforced again)", + "bypass closed, door 2: on the stock contrast boot (owner exists, no OS_DISABLE_SIGNUP), the same forge refuses 403 SELF_REGISTRATION_CLOSED — the default audience posture invite_only closes it (audience-posture.ts:136-160, :403-410); record WHICH door refused on each boot", + "wizard never re-renders: GET bootstrap-status → {hasOwner:true}; anonymous /setup redirects through the login contract (/login?redirect=%2Fsetup); the signed-in owner's /setup resolves to the platform-administration deep link (the route's SECOND meaning, objectui#2794 — not a RouteNotFound, not the wizard)" + ], + "acceptance": [ + { + "clause": "bootstrap-status is public and truthful on both sides: the zero-user boot answers {hasOwner:false} to an unauthenticated GET, and after the wizard completes the same call answers {hasOwner:true}", + "oracle": "api", + "verify": "GET /api/v1/auth/bootstrap-status with no credentials, before and after; the route is ledgered and mounted ahead of the auth wall (auth-plugin.ts:1895,1936-1949)", + "evidence": "the two bootstrap-status responses" + }, + { + "clause": "the deliberate bypass: on the zero-user boot the wizard's POST /sign-up/email succeeds EVEN under OS_DISABLE_SIGNUP=true — the bypass is scoped to the zero-user state, keyed on the human-user probe, and both gates (disableSignUp flip + audience isBootstrap) admit exactly this creation", + "oracle": "api", + "verify": "the sign-up request 2xx on the disableSignUp boot while userCount==0 (auth-manager.ts:1889-1902; audience-posture.ts:383-385); get-session then identifies the owner", + "evidence": "the sign-up trace + the get-session read" + }, + { + "clause": "the personal organization carries the typed name: the auto-provisioned bootstrap org is RENAMED to the wizard's org field (display name; slug preserved for CJK-only names) and no second org is created", + "oracle": "api", + "verify": "GET /api/v1/auth/organization/list post-bootstrap shows exactly one org whose name equals the typed value (SetupPage handleSubmit: refreshOrganizations poll then updateOrganization — never createOrganization)", + "evidence": "the organization list read" + }, + { + "clause": "the exit is a full-document navigation and the landing console renders the owner's session — never the anonymous-era metadata (the SPA shell is not built to survive an anonymous→owner transition in place)", + "oracle": "network", + "verify": "the exit is a document navigation (window.location.assign), and the landed console's app list is the owner's (non-empty where the app ships apps), cross-checked against an authed /api/v1/meta/app read (objectui#4181, SetupPage.tsx:31-56)", + "evidence": "the navigation trace + the landed screenshot + the meta read" + }, + { + "clause": "the bypass CLOSES on both doors the moment an owner exists: a post-bootstrap anonymous sign-up is refused server-side under OS_DISABLE_SIGNUP=true (toggle re-enforced), and on a stock boot it is refused 403 SELF_REGISTRATION_CLOSED (default invite_only posture) — there is never a second self-minted 'owner'", + "oracle": "api", + "verify": "the two forged sign-ups, one per boot, each non-2xx with the door named; no new sys_user row after either", + "evidence": "the two refusals + the unchanged user reads" + }, + { + "clause": "/setup keeps its two meanings honestly: with hasOwner:true the wizard never renders — anonymous visitors get the /login?redirect=%2Fsetup contract, and the signed-in owner lands on the platform-administration deep link", + "oracle": "screenshot", + "verify": "screenshot the anonymous redirect landing and the owner's /setup destination; neither shows the first-run wizard (SetupRoute → ProtectedRoute + SetupRedirect; objectui#2794)", + "evidence": "the two screenshots" + } + ], + "negative": [ + "the wizard rendering on a boot that already has an owner is a FAIL — it would offer account creation past every gate; reproduce twice and rule out a stale bootstrap-status read first", + "a second anonymous sign-up succeeding after bootstrap (either boot) is a privilege FAIL of the highest severity — apply RUNNER rule 7, and note rule 2's auth carve-out governs publication", + "an SPA-route exit that drops the new owner into an appless/anonymous-era console is the objectui#4181 regression — FAIL, not a cosmetic", + "a CJK-only org name silently keeping the bootstrap default name is the swallowed-rename failure SetupPage's slug guard exists for — FAIL", + "the wizard creating a SECOND organization instead of renaming the bootstrap one is a FAIL (single-org mode forbids createOrganization — the fall-through was a real historical bug, see SetupPage handleSubmit comment)" + ], + "traps": ["first-boot-cold-start", "auth-state-leak", "hydration-race"], + "source": [ + "objectui apps/console/src/components/SetupRoute.tsx (one URL, two surfaces — first-run wizard vs platform-admin deep link, objectui#2794) + setupEntry.ts (the latched verdict: fresh only from an unauthenticated probe)", + "objectui apps/console/src/pages/auth/SetupPage.tsx:1-11 (renders only at hasOwner:false; creates owner + names the auto-provisioned personal org) + :31-56 (why both exits are FULL-PAGE navigations — objectui#4181) + handleSubmit (rename-not-create, refreshOrganizations poll, slug guard)", + "packages/plugins/plugin-auth/src/auth-plugin.ts:1895 (bootstrap-status exempt from the auth wall) + :1936-1949 (the route: hasOwner from dataEngine.count('sys_user'))", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:179 (GET /api/v1/auth/bootstrap-status = auth.bootstrapStatus, objectstack-mount)", + "packages/plugins/plugin-auth/src/auth-manager.ts:1485-1492 (the bypass's contract comment) + :1889-1902 (the before-hook: isBootstrapCreation flips disableSignUp for this request — [#11767] fixed the inert probe) + :3785-3800 (isBootstrapCreation: human rows, fail-closed on a full page)", + "packages/plugins/plugin-auth/src/audience-posture.ts:317-323 (isHumanUserRow) + :383-385 (decideAudienceAdmission isBootstrap arm) + :136-160 (undeclared audience ⇒ invite_only, maintainer ruling 2026-08-24)", + "packages/cli/src/commands/dev.ts:141-142,339 (seed-admin defaults ON — why a zero-user boot needs --no-seed-admin)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep ([x2] hit, angles 1+4): the zero-user first-run wizard had no item. Encodes the public bootstrap-status door, the deliberate disableSignUp + audience-gate bypass scoped to the zero-user state, the full-document exit hazard (objectui#4181), the rename-not-create org contract, both closed-door contrasts, and /setup's two meanings (objectui#2794); recorded the rows-vs-humans divergence between the two bootstrap probes as observe-and-record", "ref": "#sweep-2026-08-30" } + ] + }, + { + "id": "identity-auth.self-signup-gate", + "title": "Self-signup gate: the default audience posture (invite_only) refuses at the server with a LOUD code, a pending invitation carves through, and OS_DISABLE_SIGNUP bounces the UI AND the endpoint — the register form's presence is deliberate, never the gate", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["anonymous uninvited registrant", "an invited email's registrant", "org admin (mints the invitation)", "env owner (sets OS_DISABLE_SIGNUP)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the stock boot for the invite_only legs — an UNDECLARED audience resolves to posture invite_only (maintainer ruling 2026-08-24, epic #11723; audience-posture.ts:136-160), and the showcase declares none, so the refusal legs run on stock fixtures", + "a pending invitation for the carve-out leg — runtime-creatable: as admin, POST /api/v1/auth/organization/invite-member for a fresh email on the default org (the organization plugin defaults on, auth-manager.ts:5026)", + "a second boot with OS_DISABLE_SIGNUP=true exported for the disabled legs" + ], + "knownGaps": [ + "the email_domain and open posture variants need a CONFIG-DECLARED audience — audience is config-only (AuthConfigSchema.audience, packages/spec/src/system/auth-config.zod.ts:509; no env knob) and must declare selfRegistrationPermissionSet (entry validation refuses a permitting posture without it, audience-posture.ts:222-233). No stock example declares one, so those variants are blocked(fixture) without a bespoke config boot", + "BOUNDARY: on a permitting posture, requireEmailVerification is FORCED on (#11739), so the completed-signup → challenge → verify loop belongs to identity-auth.email-verification-loop — this item owns the ADMISSION decision only; do not double-cover", + "the zero-user bootstrap bypass is the ONE admission exception and is owned by identity-auth.first-run-owner-bootstrap — cross-reference, do not re-test here" + ] + }, + "steps": [ + "stock boot: GET /api/v1/auth/config and record features.audiencePosture ('invite_only') and emailPassword.disableSignUp (false) — then confirm the login page still offers the register link and /register still renders the form. That presence is DELIBERATE: the posture does not force disableSignUp, because hiding the form would dead-end invited users (getPublicConfig comment, auth-manager.ts:4948-4959)", + "submit the register form (and separately POST /api/v1/auth/sign-up/email directly) for an UNINVITED address: capture the 403 with code SELF_REGISTRATION_CLOSED — a loud, deterministic refusal, NOT a synthetic 200. The sign-up route raises the audience refusal BEFORE better-auth's anti-enumeration shield can swallow a creation-seam 403 into a fake success (auth-manager.ts:1840-1877 — the measured black-hole this ordering exists to prevent)", + "confirm no sys_user row was created for the refused address (admin read)", + "carve-out: as admin mint an invitation for a fresh email, then sign up with EXACTLY that email on the same stock boot — admitted (a pending, unexpired sys_invitation trumps the posture; audience-posture.ts:398-402)", + "disabled boot (OS_DISABLE_SIGNUP=true): GET /auth/config shows emailPassword.disableSignUp true; navigating to /register bounces to /login preserving ?redirect (RegisterPage's probe, apps/console/src/pages/auth/RegisterPage.tsx:69-88) and the login card withholds the register link (LoginPage registerUrl passed undefined, LoginPage.tsx:298)", + "fire POST /api/v1/auth/sign-up/email directly on the disabled boot anyway and capture the server-side refusal — env wins over config/settings (readDisableSignUpEnv: OS_AUTH_SIGNUP_ENABLED inverts and wins, then OS_DISABLE_SIGNUP — auth-manager.ts:264-268; wired at :1238-1250, ssoOnly forces it true)", + "record the posture × outcome table for whichever variants this run's boots covered" + ], + "acceptance": [ + { + "clause": "advertisement parity: /api/v1/auth/config carries features.audiencePosture matching the boot's resolved posture and emailPassword.disableSignUp resolved env > config > false — and the two are INDEPENDENT axes (invite_only alongside disableSignUp:false is the correct stock shape, not a contradiction)", + "oracle": "api", + "verify": "the /auth/config reads per boot (getPublicConfig: disableSignUp at auth-manager.ts:4959, audiencePosture at :5040); stock shows invite_only + disableSignUp false together", + "evidence": "the per-boot /auth/config responses" + }, + { + "clause": "invite_only refuses server-side and LOUDLY: an uninvited sign-up answers 403 with code SELF_REGISTRATION_CLOSED — never the anti-enumeration synthetic 200 — and creates no row", + "oracle": "api", + "verify": "the direct sign-up response status+code (ADR-0112: assert the CODE, not status alone) + an admin sys_user read showing no row for that email", + "evidence": "the refusal response + the row-absence read" + }, + { + "clause": "the invitation carve-out admits: a sign-up whose email holds a pending, unexpired invitation completes on the same invite_only boot — which is exactly why the register form stays rendered under the closed posture", + "oracle": "api", + "verify": "invite-member 2xx → the invitee's sign-up 2xx → get-session identifies the new user", + "evidence": "the invitation + sign-up + session trace" + }, + { + "clause": "OS_DISABLE_SIGNUP's UI courtesy: /register bounces to /login (redirect param preserved) and the login card withholds the register link", + "oracle": "screenshot", + "verify": "screenshot the bounce landing and the login card without the sign-up affordance (screenshot first, then DOM)", + "evidence": "the two screenshots" + }, + { + "clause": "OS_DISABLE_SIGNUP's real gate: the direct sign-up endpoint refuses server-side on the disabled boot — the UI bounce is defense-in-depth, the server is the authority (ADR-0124 D1/D5)", + "oracle": "api", + "verify": "the direct POST /api/v1/auth/sign-up/email on the disabled boot returns non-2xx and creates no row", + "evidence": "the forged-request refusal + the row-absence read" + } + ], + "negative": [ + "a 200-with-synthetic-user answer to an uninvited invite_only sign-up is the swallowed-refusal black hole (fake success, no mail ever) the pre-shield ordering exists to prevent — FAIL, cite auth-manager.ts:1840-1877", + "an uninvited sign-up CREATING a row under invite_only is an audience-wall breach — apply RUNNER rule 7 before acting; publication rides rule 2's auth carve-out", + "UI-only enforcement (bounce and hidden link present, endpoint 2xx) is a FAIL — courtesy is not the gate", + "refusing an INVITED registrant on the closed posture is the invite dead-end class the carve-out exists for — FAIL", + "the register affordance disappearing on the stock invite_only boot would strand invited users — a 'fix' in the wrong direction; the deliberate form-stays-rendered shape is part of the contract" + ], + "variants": [ + "invite_only (stock default; refuse SELF_REGISTRATION_CLOSED, invitation carve-out admits — audience-posture.ts:403-410,398-402)", + "email_domain (allowlisted address admitted + granted the declared set; off-list refused EMAIL_DOMAIN_NOT_ALLOWED; matching rules pinned — last-@, case-insensitive, exact entry, no subdomain implication, +tag irrelevant, audience-posture.ts:66-79,411-421) — blocked(fixture) without a config boot", + "open (admitted + granted selfRegistrationPermissionSet; verification forced on — the loop is email-verification-loop's) — blocked(fixture) without a config boot", + "OS_DISABLE_SIGNUP=true env (UI bounce + server refusal; env > config; OS_AUTH_SIGNUP_ENABLED inverts and wins over it)", + "ssoOnlyMode (forces disableSignUp true — surface covered by identity-auth.sso-enforced-first-paint; here only the config-resolution fact)" + ], + "enumSource": { "file": "packages/spec/src/system/auth-config.zod.ts", "export": "AUDIENCE_POSTURES", "expect": 3 }, + "traps": ["dispatcher-vs-hono-route", "auth-state-leak", "absence-inference"], + "source": [ + "packages/plugins/plugin-auth/src/audience-posture.ts (the single owner of audience admission: resolveAudience default invite_only :136-160; decideAudienceAdmission :378-433 — operator/provider exempt, bootstrap admit, invitation carve-out :398-402, posture semantics; SELF_REGISTRATION_CLOSED / EMAIL_DOMAIN_NOT_ALLOWED :103-105; pinned domain rules :66-79 in the module doc)", + "packages/plugins/plugin-auth/src/auth-manager.ts:264-268 (readDisableSignUpEnv: OS_AUTH_SIGNUP_ENABLED wins, then OS_DISABLE_SIGNUP) + :1238-1250 (effective disableSignUp: ssoOnly forces true, else env ?? config) + :1840-1877 (the sign-up route raises the audience refusal BEFORE the anti-enumeration shield — measured swallow documented in place) + :4948-4959,5040 (getPublicConfig: disableSignUp NOT forced by posture, features.audiencePosture advertised)", + "packages/spec/src/system/auth-config.zod.ts:289 (AUDIENCE_POSTURES const — invite_only/email_domain/open, the named enum backing posture z.enum) + :509 (AudienceConfigSchema on AuthConfigSchema — config-only, no env knob)", + "objectui apps/console/src/pages/auth/RegisterPage.tsx:1-14,69-88 (probe /auth/config, bounce to /login when disableSignUp; server-side gate named as the source of truth in its own header)", + "objectui apps/console/src/pages/auth/LoginPage.tsx:60,136,298 (signUpDisabled from /auth/config; registerUrl withheld when disabled)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 4). CORRECTED against source from the register's hypothesis 'default boot sign-up completes': since #11739 (epic #11723, ruling 2026-08-24) the undeclared audience resolves to invite_only, so a stock-boot uninvited self-signup is REFUSED 403 SELF_REGISTRATION_CLOSED — the item encodes the posture matrix (enumSource-pinned on AUDIENCE_POSTURES), the pre-anti-enumeration-shield loud-refusal ordering, the invitation carve-out, the deliberate form-stays-rendered shape, and the OS_DISABLE_SIGNUP env gate on both the UI and the endpoint", "ref": "#sweep-2026-08-30" } + ] + }, + { + "id": "identity-auth.email-verification-loop", + "title": "Email verification loop: a challenged sign-up lands a captured verification mail, unverified sign-in is refused EMAIL_NOT_VERIFIED server-side, the link admits, resend works — and the default boot never challenges", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["a fresh registrant (unverified, then verified)", "admin (reads state)", "env owner (configures the verification boot)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a VERIFICATION-ENABLED boot — no stock boot challenges. Two ways to arm it: (a) config emailAndPassword.requireEmailVerification:true + emailVerification.sendOnSignUp:true (wired at auth-manager.ts:1259-1262 and :1324-1340), or (b) any audience posture that permits self-registration (open / email_domain), which FORCES requireEmailVerification on (#11739 invariant, auth-manager.ts:1251-1262; the explicit-false contradiction is refused at config entry, audience-posture.ts:236-244)", + "an ADMISSIBLE sign-up to feed the loop: on an (a)-style boot the default posture is still invite_only, so either mint a pending invitation for the test address first, or use a (b)-style open-posture boot (which must declare selfRegistrationPermissionSet)", + "the dev `log` mail transport so the verification mail (template auth.verify_email) and its link are CAPTURED, not sent — same harness as identity-auth.self-service-password-reset" + ], + "knownGaps": [ + "no stock boot enables verification — the enabled legs need the bespoke config boot above and are blocked(fixture) without it; the default-boot never-challenges leg runs on stock fixtures", + "capturing the verification TOKEN requires the log transport; a real provider with no capture hook blocks the link-admits and resend clauses only" + ] + }, + "steps": [ + "on the verification boot, sign up a fresh admissible address: the console routes to /verify-email-prompt after sign-up (RegisterPage header contract) and the verification mail lands at the log transport via template auth.verify_email — capture the link/token (sendVerificationEmail, auth-manager.ts:1350-1370: template failures THROW into the log rather than silently dropping)", + "attempt POST /api/v1/auth/sign-in/email for the unverified user: capture the refusal carrying EMAIL_NOT_VERIFIED, and in the browser confirm the login page redirects that error into /verify-email-prompt (LoginPage.tsx:339,346,360)", + "resend from the prompt page (useAuth().sendVerificationEmail → POST /api/v1/auth/send-verification-email, ledgered auth-route-ledger.ts:165) and capture the second mail at the transport", + "follow the captured link: the console's /verify-email consumes ?token= via POST /api/v1/auth/verify-email — the page deliberately uses the POST variant for JSON control; the GET variant 302-redirects (VerifyEmailPage.tsx:45-57; ledger row GET /api/v1/auth/verify-email at :172) — and renders the success state", + "sign in again with the same credentials: admitted; get-session identifies the user", + "feed a garbage/expired token to /verify-email: the page renders its designed error state (named message, retry path), and the user's verification state is unchanged (sign-in still refused)", + "default-boot contrast (stock showcase, no verification config): an admitted creation (seeded persona or invited registrant) signs in immediately with NO challenge — and /auth/config advertises emailPassword.requireEmailVerification false", + "advertisement cross-check on the (b)-style boot: /auth/config MUST advertise requireEmailVerification true (the forced flag is mirrored — the advertised value may never disagree with the wired one)" + ], + "acceptance": [ + { + "clause": "the challenge is server-enforced: an unverified user's sign-in is refused with EMAIL_NOT_VERIFIED — the prompt page is UX, the refusal is the gate", + "oracle": "api", + "verify": "the sign-in response for the unverified user is non-2xx carrying the EMAIL_NOT_VERIFIED code; get-session holds no authenticated session for them", + "evidence": "the refused sign-in + the session read" + }, + { + "clause": "sendOnSignUp produces a real mail artifact: the sign-up lands an auth.verify_email templated mail at the log transport containing a working verification link", + "oracle": "log", + "verify": "the transport output for the sign-up carries the template render with a tokened link (auth-manager.ts:1350-1370)", + "evidence": "the captured transport output" + }, + { + "clause": "the captured token verifies and flips admission: POST /api/v1/auth/verify-email with the token succeeds, and the previously-refused sign-in now returns a session", + "oracle": "api", + "verify": "verify-email 2xx → sign-in 2xx → get-session identifies the user", + "evidence": "the verify + sign-in + session trace" + }, + { + "clause": "resend issues a fresh working mail: the prompt page's resend lands a second capture whose link also verifies", + "oracle": "log", + "verify": "the second transport capture after POST /send-verification-email; its token is accepted (or the first's already-consumed state is reported loudly — record which)", + "evidence": "the second capture + the follow-up verify response" + }, + { + "clause": "a garbage or expired token is refused loudly and changes nothing: the /verify-email page renders its designed error state (never a blank page or a fake success), and the account stays unverified", + "oracle": "api", + "verify": "the bad-token verify-email response is non-2xx; a subsequent sign-in is still refused EMAIL_NOT_VERIFIED; screenshot the error state", + "evidence": "the refusal + the still-refused sign-in + the error-state screenshot" + }, + { + "clause": "the default boot never challenges — both sides of the gate: with no verification config (and the stock invite_only posture) an admitted user signs in with no verification step, and /auth/config advertises requireEmailVerification false; on the forced-verification (permitting-posture) boot the same flag advertises TRUE (the mirror may never disagree with the wiring)", + "oracle": "api", + "verify": "the two /auth/config reads (getPublicConfig mirror, auth-manager.ts:4963-4967) + an unchallenged stock sign-in trace", + "evidence": "the two config reads + the stock sign-in trace" + } + ], + "negative": [ + "a UI-only challenge (prompt page shown but the unverified sign-in succeeds server-side) is a FAIL — the refusal is the enforcement", + "a verification mail silently vanishing (no transport capture AND no thrown/logged send failure) is a FAIL — the send seam throws into the log on template/transport failure rather than dropping (auth-manager.ts:1350-1358); silence is the defect", + "a permitting-posture boot advertising requireEmailVerification false is the #11739 mirror regression — the advertised flag disagreeing with the wired forcing is itself the FAIL, independent of behavior", + "the default stock boot challenging sign-ins would be a config regression (verification leaking on) — FAIL", + "a garbage token producing a success state (or verifying the account) is a security FAIL" + ], + "traps": ["eventual-consistency", "auth-state-leak", "hydration-race"], + "source": [ + "packages/plugins/plugin-auth/src/auth-manager.ts:1251-1262 (requireEmailVerification wiring — [#11739]: a self-registration-permitting posture FORCES it on; otherwise config passthrough) + :1324-1370 (emailVerification block: sendOnSignUp/sendOnSignIn/autoSignInAfterVerification/expiresIn passthrough; sendVerificationEmail via template auth.verify_email, failures thrown into the log) + :4963-4967 (getPublicConfig mirrors the forced flag)", + "packages/plugins/plugin-auth/src/audience-posture.ts:236-244 (entry validation refuses the permitting-posture + explicit-false contradiction)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:165 (POST /api/v1/auth/send-verification-email = auth.sendVerificationEmail) + :172 (GET /api/v1/auth/verify-email = auth.verifyEmail)", + "objectui apps/console/src/App.tsx:174-175 (/verify-email + /verify-email-prompt routes)", + "objectui apps/console/src/pages/auth/VerifyEmailPage.tsx:45-57 (consumes ?token= via the POST variant — GET 302s, POST returns JSON so the SPA controls the post-verify UX)", + "objectui apps/console/src/pages/auth/VerifyEmailPromptPage.tsx:1-7 (shown after sign-up or an EMAIL_NOT_VERIFIED-blocked sign-in; resend via useAuth().sendVerificationEmail)", + "objectui apps/console/src/pages/auth/LoginPage.tsx:339,346,360 (EMAIL_NOT_VERIFIED → /verify-email-prompt redirect + the named error copy)", + "packages/plugins/plugin-email/src/transports/index.ts (the dev `log` capture transport — same harness as identity-auth.self-service-password-reset)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 4): the verification loop (challenge, capture, verify, resend, default-never-challenges) had no item. Grounded the #11739 posture-forces-verification invariant and its advertised mirror, the console's three surfaces (/verify-email POST-variant consumption, /verify-email-prompt resend, LoginPage's EMAIL_NOT_VERIFIED redirect), and the admissibility precondition the audience wall adds to the fixture", "ref": "#sweep-2026-08-30" } + ] + }, + { + "id": "identity-auth.workspace-org-switch", + "title": "Workspace switcher: org chrome follows posture × membership count (nothing under single; read-only indicator at one membership; switcher at two), and a switch is a full-document reload after which every tenant-scoped read answers from the new org", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["a member with one org membership", "the same member after gaining a second membership", "admin (provisions the second org)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a WALL-ENFORCING tenancy posture boot: OS_TENANCY_POSTURE=group (or isolated) — the stock default resolves to `single` (resolveTenancyPosture, packages/types/src/env.ts:145-162), where multiOrgEnabled is false and NO org chrome may render at all (postureEnforcesWall drives both the flag and the UI predicate — auth-manager.ts:4992)", + "a second organization + membership for the test user — runtime-creatable on the posture boot via the better-auth org endpoints (POST /api/v1/auth/organization/create, then the platform-admin-gated POST /api/v1/auth/organization/add-member — auth-route-ledger.ts organization family); the org-create gate follows the SAME posture derivation as the advertised flag, so it admits on this boot" + ], + "knownGaps": [ + "not runnable on the stock boot: `single` posture renders no switcher and no indicator BY DESIGN (that absence is itself this item's clause 1, runnable on stock) — every other clause needs the OS_TENANCY_POSTURE boot and is blocked(fixture) without it" + ] + }, + "steps": [ + "posture precheck per boot: GET /api/v1/auth/config and record features.tenancyPosture and features.multiOrgEnabled (derived from the same postureEnforcesWall call as the org-create gate — auth-manager.ts:4986-4996)", + "stock (`single`) boot, any membership count: confirm the top bar shows NEITHER the switcher NOR the organization indicator — under single the wall is inert and the chrome must not imply a scope (CurrentOrganizationIndicator returns null unless postureHasOrgWall, objectui CurrentOrganizationIndicator.tsx:53; the predicate's spec-parity is test-locked, useTenancyPosture.ts)", + "wall-posture boot, ONE membership: the read-only CurrentOrganizationIndicator renders the org name with NO click target, no menu (objectui#5287 — the name is context, not navigation); the switcher renders nothing (WorkspaceSwitcher.tsx:75 orgList.length <= 1)", + "provision the second org + membership; reload: the switcher renders (data-testid workspace-switcher) and the indicator yields (it renders ONLY at exactly one membership, CurrentOrganizationIndicator.tsx:59)", + "under `group` posture, open the dropdown and capture the labeling: 'Working organization' + the writes-here/reads-span-all hint (data-testid workspace-switcher-group-hint, WorkspaceSwitcher.tsx:103-116); under `isolated` it reads 'Switch organization'", + "switch to the second org: the client calls switchOrganization (POST /api/v1/auth/organization/set-active, auth-route-ledger.ts:270) and then performs a FULL-DOCUMENT navigation to the console root (window.location.href = resolveRootUrl(), WorkspaceSwitcher.tsx:79-91) — capture that it is a document reload, not a SPA transition", + "after the reload: GET /api/v1/auth/organization/get-active-member confirms the new active org; re-drive one tenant-scoped data read and one /meta read and confirm they answer from the NEW org — pick one row/app id that exists only in the OLD org and prove its absence (the org change drops the whole metadata cache, objectui#4486, MetadataProvider.tsx:834-860)", + "failure leg: as the member, forge POST /api/v1/auth/organization/set-active with an org id they hold NO membership in; capture the server refusal and confirm the active org is unchanged (get-active-member) and the UI never renders the foreign org as active" + ], + "acceptance": [ + { + "clause": "the affordance matrix holds: single posture → no org chrome at any membership count; wall posture + one membership → the read-only indicator only (no control, no menu); wall posture + two or more → the switcher only, never both", + "oracle": "screenshot", + "verify": "screenshot the top bar in each of the three cells (screenshot first, then DOM for the data-testids); cross-check the posture from /auth/config features.tenancyPosture", + "evidence": "the three top-bar screenshots + the config reads" + }, + { + "clause": "a switch is server-real: set-active answers 2xx and get-active-member subsequently reports the new organization as active", + "oracle": "api", + "verify": "the set-active response + the post-reload get-active-member read naming the new org id", + "evidence": "the two responses" + }, + { + "clause": "the switch exits through a FULL-DOCUMENT reload to the console root — deliberately, so the new active org propagates to every data scope and the landing resolution re-runs (mirrors OrganizationsPage)", + "oracle": "network", + "verify": "the navigation after the dropdown click is a document load of the console root, not a SPA route change (WorkspaceSwitcher.tsx:79-91 — the comment states the design intent)", + "evidence": "the navigation trace" + }, + { + "clause": "tenant-scoped reads follow the switch: after the reload, data and /meta reads answer from the new org — an id that exists only in the previous org is ABSENT (one organization's metadata never survives into another's reads, objectui#4486)", + "oracle": "api", + "verify": "the post-switch data read + meta read; the old-org sentinel id is not in either response", + "evidence": "the before/after reads + the sentinel id" + }, + { + "clause": "group-posture honesty: the dropdown labels the active org as the WORKING organization with the writes-here/reads-span-all hint — because under group the active org bounds writes, not reads — while isolated labels it a plain switch", + "oracle": "screenshot", + "verify": "the dropdown screenshot per posture; workspace-switcher-group-hint present under group, absent under isolated", + "evidence": "the two dropdown screenshots" + }, + { + "clause": "a forged or failed switch refuses cleanly: set-active to a non-membership org is refused server-side, the active org is unchanged, and the UI surfaces an error rather than rendering the foreign org as active", + "oracle": "api", + "verify": "the forged set-active non-2xx + an unchanged get-active-member + the UI state after the failure", + "evidence": "the refusal + the unchanged active-org read + the screenshot" + } + ], + "negative": [ + "ANY org chrome (indicator or switcher) rendering under `single` posture is the rejected-chrome regression the posture gate exists for — FAIL, cite the #5287/#5233 reasoning, not a cosmetic", + "a switch that updates the displayed org while reads still answer from the previous org is a tenancy FAIL — apply RUNNER rule 7; stale metadata surviving the switch until a cache TTL is the objectui#4486 regression specifically", + "a one-entry dropdown at a single membership (the rejected design), or the indicator carrying a click target/menu, is a FAIL against the #5287 no-control rule", + "set-active succeeding for an org the caller holds no membership in is an authorization FAIL — rule 2's carve-out governs publication", + "the switcher performing an SPA-only transition (no document reload) would leave every mounted data scope on the old org — FAIL even if the header label updates" + ], + "traps": ["cache-staleness", "auth-state-leak", "shared-browser-tab", "hydration-race"], + "source": [ + "objectui packages/app-shell/src/layout/WorkspaceSwitcher.tsx:1-21 (the surface contract: single-org renders nothing, multi-org switches with full-page reload, group posture relabels) + :67,75 (the two null gates) + :79-91 (handleSwitch: switchOrganization then window.location.href = resolveRootUrl()) + :103-116 (group 'Working organization' label + hint) + :138-147 (manage-members / create-workspace entries, create gated on multiOrgEnabled)", + "objectui packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx:1-60 (objectui#5287: read-only name for exactly-one membership, gated on postureHasOrgWall — single posture renders nothing by design; no click target)", + "objectui packages/app-shell/src/hooks/useTenancyPosture.ts (postureHasOrgWall restated locally for bundle size, spec-parity test-locked)", + "objectui packages/app-shell/src/providers/MetadataProvider.tsx:834-860 (objectui#4486: an org change drops the whole metadata cache — one organization's metadata never survives into another organization's reads; the reloading switch paths and the SPA-internal path both covered)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:270 (POST /api/v1/auth/organization/set-active = organizations.setActive, requires organization)", + "packages/plugins/plugin-auth/src/auth-manager.ts:4986-4996 (multiOrgEnabled = postureEnforcesWall(effectiveTenancyPosture()) — the SAME call the org-create gate makes, #5233/#5261) + :5040-5041 area (features advertised)", + "packages/types/src/env.ts:145-162 (resolveTenancyPosture: OS_TENANCY_POSTURE, invalid value refuses boot; unset falls back to `single` unless legacy multi-org env)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 1): the workspace-switch surface had no item. CORRECTED against source from the register's 'single membership → indicator' hypothesis: the indicator is posture-gated (postureHasOrgWall — group/isolated only), so the stock `single` boot renders NO org chrome at all and the whole matrix needs an OS_TENANCY_POSTURE boot; encoded the three-cell affordance matrix, the deliberate full-document reload, the #4486 cache-drop read-follow proof, group-posture labeling, and the forged-switch refusal", "ref": "#sweep-2026-08-30" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 56ff65b912..cd41d38b65 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -506,10 +506,10 @@ }, { "id": "integration-system.webhook-lifecycle", - "title": "Outbound webhooks materialize (spec object→object_name, isActive→active), fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, reject retired trigger kinds, and never clobber admin edits", + "title": "Outbound webhooks materialize (spec object→object_name, isActive→active), fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, redeliver terminal rows tenant-scoped via POST /api/v1/webhooks/redeliver, reject retired trigger kinds, and never clobber admin edits", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "fixtures": { @@ -539,7 +539,7 @@ "run a predicate multi-update and multi-delete (multi:true) matching several rows — through a FLOW node, not REST: author update_record / delete_record nodes with multi:true and fire them via the api trigger (the REST batch routes strip options.multi, #3897 — see the knownGap); capture the bulk deliveries and their { object, matched } shape", "read sys_http_delivery over /api/v1/data: one row per delivery with status/attempts/lastStatusCode", "kill the receiver and mutate again; re-read the delivery row through its retry/failure states", - "author a scratch webhook with triggers:['undelete'] and one with ['api']; build both and capture the parse errors", + "restore the receiver, let the failing row reach a terminal status (failed/dead), then POST /api/v1/webhooks/redeliver with { deliveryId: } as an authenticated user: capture the 200 { id, status } and the receiver's re-hit — compare the replayed X-Objectstack-Signature byte-for-byte against the original delivery's; re-read the row; then probe the refusals: anonymous POST (401), non-JSON body (400 INVALID_REQUEST), body without deliveryId (400 MISSING_REQUIRED_FIELD), a garbage/unknown deliveryId (404), a row still pending/in_flight (409 DELIVERY_NOT_ELIGIBLE)", "redeploy/reboot and confirm the admin-edited row survived re-seed (customized rows are never clobbered)" ], "acceptance": [ @@ -567,6 +567,12 @@ "verify": "receiver-side headers + the outbox rows over the data API", "evidence": "headers + delivery rows" }, + { + "clause": "POST /api/v1/webhooks/redeliver replays a TERMINAL delivery (status success/failed/dead with attempts > 0): the row resets and re-dispatches carrying the STORED HMAC signature byte-for-byte (signed at enqueue, replayed verbatim — still verifiable against the secret), the row updates, and the response is { id, status }. The refusal matrix never 500s for a caller-fixable state: anonymous → 401 UNAUTHENTICATED; non-JSON → 400 INVALID_REQUEST; missing deliveryId → 400 MISSING_REQUIRED_FIELD; unknown OR cross-organization deliveryId → 404 RESOURCE_NOT_FOUND (the caller's activeOrganizationId is threaded into the lookup, #10740 — another org's row is simply not found, not forbidden-with-a-hint); non-terminal row → 409 DELIVERY_NOT_ELIGIBLE; a PARKED row (attempts === 0, recordUndeliverable's record of a delivery never prepared) → 409 DELIVERY_NEVER_SENT, because redelivering it would be an UNSIGNED first delivery (#7799/#8069 — the redeliver guard also refuses when the signing configuration is gone, fail-closed on a guard that throws). ⚠ PIN THE POSTURE: there is deliberately NO capability gate above the auth floor — 'every authenticated user counts' (the plugin's own words); a non-admin session redelivering successfully is the designed behavior, not a missing-gate finding, and a run must neither file it nor 'fix' it", + "oracle": "api", + "verify": "the redeliver 200 + the receiver's replayed signature equal to the original (pinned byte-for-byte in http-signature-at-rest.integration.test.ts:209-221); each refusal against webhook-outbox-plugin.ts:386-446 (401/400/400/404 arms) and http-outbox.ts:346-403 (assertHttpRedeliverable / assertRedeliverAllowed — the 409 pair and the guard); the no-capability posture and the #10740 tenant threading in the route's own docblock (:358-375)", + "evidence": "the redeliver response + replayed receiver hit + the refusal responses, keyed by arm" + }, { "clause": "a retired trigger kind fails at parse with the enum rejection — WebhookTriggerType is exactly [create, update, delete, bulk_update, bulk_delete]; undelete/api never register silently dead (the #3196 enforce-or-remove gate the #3358 sweep verified)", "oracle": "build", @@ -597,7 +603,8 @@ "packages/spec/src/automation/webhook.zod.ts (WebhookTriggerType enum + why undelete/api are absent; materialization contract; strict shape #4001)", "packages/spec/liveness/webhook.json (all 11 props live via the #3489 bridge; per-prop line refs)", "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts + auto-enqueuer.ts (remaps; trigger→event mapping incl. the #4639 bulk pair; #3196 unknown-trigger warn; seed-not-clobber)", - "packages/services/service-messaging/src/http-outbox.ts (delivery statuses, attempts, redeliver contract) + plugin-webhooks/webhook-outbox-plugin.ts (sys_http_delivery nav)", + "packages/services/service-messaging/src/http-outbox.ts (delivery statuses, attempts; :305-403 HttpRedeliverError + assertHttpRedeliverable/assertRedeliverAllowed — terminal-only, the attempts===0 parked-row refusal, the fail-closed guard) + packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts:209-221 (redelivery replays the stored signature byte-for-byte)", + "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:358-449 (POST /api/v1/webhooks/redeliver — session auth with NO capability gate by design ('every authenticated user counts'), #10740 activeOrganizationId threading, the 401/400/404/409/500 arms) + :310-344 (the #8069 redeliver-guard install; its ABSENCE is an error-level log, not a silent pass)", "packages/rest/src/rest-server.ts:10461-10492 (#3897 — the batch routes parse against the spec contract and Zod STRIPS unknown keys, so options.multi/options.where cannot ride in; deleteMany deletes per id. This is why the bulk clause must be driven from a flow node, not REST)", "examples/app-showcase/src/automation/webhooks/index.ts (the shipped inactive fixture and its activation story)", "#3358 §9 (webhook undelete/api trigger removal gate)" @@ -626,6 +633,12 @@ "date": "2026-08-11", "change": "recorded the run #7690 note that predicate (multi:true) writes are unreachable over REST BY DESIGN — #3897 strips options.multi from the batch routes as a security boundary — so the bulk_update/bulk_delete clause must be driven from a flow update_record/delete_record node fired through the api trigger. Landed as a fixtures.knownGaps entry + a corrected step + a source citation so the next sweep does not re-derive it or file the 400 as a defect", "ref": "#7745" + }, + { + "revision": 5, + "date": "2026-08-30", + "change": "coverage sweep: added the redeliver clause — POST /api/v1/webhooks/redeliver (a raw hono mount, webhook-outbox-plugin.ts:386-446) had no coverage. Asserts the terminal-row replay (stored HMAC signature re-sent byte-for-byte), the row update and { id, status } answer, and the full refusal matrix: 401 anonymous, 400 INVALID_REQUEST / MISSING_REQUIRED_FIELD, 404 for unknown AND cross-org ids (#10740 tenant threading), 409 DELIVERY_NOT_ELIGIBLE / DELIVERY_NEVER_SENT (the #8069 parked-row and gone-signing-config refusals — never a 500). Pins the deliberate NO-capability posture above the auth floor ('every authenticated user counts' — the plugin's own words) so a run neither files it as a missing gate nor widens it. Step + sources added", + "ref": "#sweep-2026-08-30" } ], "enumSource": { @@ -1044,14 +1057,15 @@ }, { "id": "integration-system.datasource-admin-lifecycle", - "title": "The /api/v1/datasources admin lifecycle: static driver catalog, runtime create with provenance+health, secret never echoes (hasSecret only), bad drafts 400 DATASOURCE_ADMIN_ERROR, unwired federation degrades 503 naming external-datasource", + "title": "The /api/v1/datasources admin lifecycle: all 11 routes behind ONE manage_platform_settings floor (drivers catalog included), runtime create with provenance+health, draft test probe, credential migration into the store, secret never echoes (hasSecret only), bad drafts 400 DATASOURCE_ADMIN_ERROR, unwired federation degrades 503 naming external-datasource", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ - "admin" + "admin — authenticated AND holding manage_platform_settings (the whole family's capability since #9391/#9593)", + "a second authenticated persona holding NO capability, plus an anonymous probe (for the uniform-floor clause)" ], "fixtures": { "app": "showcase", @@ -1065,21 +1079,42 @@ ] }, "steps": [ - "boot showcase via os dev (serve.ts mounts /api/v1/datasources by default on a non-memory engine); sign in as admin", - "GET /api/v1/datasources/drivers; confirm the static catalog (memory/sqlite/postgres/mysql/mongo, each with a configSchema) — this route has NO service dependency and answers even before any datasource-admin service is wired", + "boot showcase via os dev (serve.ts mounts /api/v1/datasources by default on a non-memory engine); sign in as the admin holding manage_platform_settings; also mint an authenticated session holding NO capability and keep an anonymous client", + "probe the uniform floor: GET /api/v1/datasources, GET /api/v1/datasources/drivers and one write (POST /api/v1/datasources) each anonymous (expect 401 UNAUTHENTICATED — do NOT misread the drivers 401 as 'catalog gone': the route is static but gated since #9391) and as the no-capability session (expect 403 PERMISSION_DENIED naming manage_platform_settings) — requireDatasourceAdmin runs on all 11 routes BEFORE any service resolution", + "GET /api/v1/datasources/drivers as the admin; confirm the static catalog (memory/sqlite/postgres/mysql/mongo, each with a configSchema) — the route has NO service dependency (it answers even when the datasource-admin service is unwired), but it is NOT anonymous-available", + "POST /api/v1/datasources/test with an UNSAVED sqlite draft { name: 'qa_ds_probe', driver: 'sqlite', config: { file: '' }, secret: '...' } — the wizard's pre-Save probe; capture the result AND confirm nothing persisted (the subsequent list is unchanged); note the route is registered before the :name routes so the literal 'test' segment is never captured as a datasource name (admin-routes.ts:625-638)", "POST /api/v1/datasources with a sqlite-file draft { name: 'qa_ds_probe', driver: 'sqlite', config: { file: '' }, secret?: '...' }; capture status + the returned datasource", "GET /api/v1/datasources; confirm qa_ds_probe appears with origin:'runtime' and a health field", "GET /api/v1/datasources/qa_ds_probe; inspect the body for config + a hasSecret flag and confirm the cleartext secret value is ABSENT", "POST /api/v1/datasources with a bad draft (invalid config shape / missing required); capture status + code", + "POST /api/v1/datasources/qa_ds_probe/migrate-credential (#8155): the fresh, cleanly-created row has nothing to re-home, so capture the 200 whose result reports the plan outcome (action 'none', status 'already-bound'/'nothing-to-migrate' — datasource-credential-migration.ts:100) rather than a 400; for the live 'bind' arm a stored row still carrying an inline cleartext credential must be PLANTED directly (the current create path splits `secret` out, so no parse-produced row has one — same fixture caveat as datasource-credential-refusal-matrix's legacy-alias clause); if it cannot be planted, score that arm from the unit pin and record blocked(fixture)", "GET /api/v1/datasources/qa_ds_probe/remote-tables (an external-datasource-served route): on a stock os dev boot federation IS wired (serve.ts:2966-2979) so expect a WIRED answer (2xx, or a 400 EXTERNAL_DATASOURCE_ERROR from the introspector — not a 503) and score the 503 clause via its unit pin; only a boot that genuinely lacks the service (dynamic import failed / custom host without the plugin) shows the 503 — capture which service the answer names either way", "GET /api/v1/datasources/does-not-exist; capture the 404" ], "acceptance": [ { - "clause": "the driver catalog is static and always-available: GET /api/v1/datasources/drivers returns the curated driver set (memory/sqlite/postgres/mysql/mongo) each with a projected configSchema, with NO datasource-admin service dependency", + "clause": "the driver catalog is static but NOT anonymous: to the entitled admin GET /api/v1/datasources/drivers returns the curated driver set (memory/sqlite/postgres/mysql/mongo) each with a projected configSchema, with NO datasource-admin service dependency — while an unauthenticated probe gets the floor's 401, deliberately (the family's floor is uniform on purpose: 'a family whose floor has one hole is a family whose floor has to be read route by route', admin-routes.ts:95-100). A 401 here is the floor working, never 'catalog broken'", + "oracle": "api", + "verify": "the admin's drivers body against DRIVER_CATALOG (driver-catalog.ts); the anonymous 401 on the same route; route needs no service (admin-routes.ts:510-516)", + "evidence": "the two drivers responses (admin 200, anonymous 401)" + }, + { + "clause": "ONE uniform capability floor guards all 11 routes — reads, writes and the static catalog alike: anonymous → 401 UNAUTHENTICATED (shouldDenyAnonymous, fail-closed on an unresolvable identity), authenticated-without-manage_platform_settings → 403 PERMISSION_DENIED whose message names the capability — both decided BEFORE any service is resolved and before any handler body runs (#9391/#9593)", + "oracle": "api", + "verify": "the six floor probes of step 2 against requireDatasourceAdmin (admin-routes.ts:404-417; DATASOURCE_ADMIN_CAPABILITY = 'manage_platform_settings', :261) — every route in the registrar opens with `if (await requireDatasourceAdmin(req, res)) return;`", + "evidence": "the 401/403 responses for a read, the catalog, and a write" + }, + { + "clause": "POST /api/v1/datasources/test probes an UNSAVED draft with NO persistence: the inline draft (+ optional cleartext secret, split out via splitSecret before the service sees the draft) is connection-tested and answered, and no datasource row exists afterwards; the literal 'test' segment is registered before the :name routes so it is never captured as a datasource name — distinct from POST /:name/test, which round-trips a SAVED datasource", + "oracle": "api", + "verify": "the test response + an unchanged GET /api/v1/datasources afterwards (admin-routes.ts:625-638; the saved-name twin at :575-585); the ledger rows for both spellings (datasource-route-ledger.ts:150-151, :164-165)", + "evidence": "the probe response + the unchanged list" + }, + { + "clause": "POST /:name/migrate-credential re-homes a stored inline cleartext credential into the secret store and drops the inline key — afterwards the read shows hasSecret with the cleartext absent — while a row it cannot re-home safely answers 200 with the plan outcome (action 'refuse' carries operator-facing reason + remedy; action 'none' reports already-bound / nothing-to-migrate), NOT a 400: the datasource is intact and the operator asked a question (#8155). Per-datasource and operator-initiated by construction — no batch spelling exists", "oracle": "api", - "verify": "the drivers body against DRIVER_CATALOG (driver-catalog.ts); route answers even when the admin service is unwired", - "evidence": "the drivers response" + "verify": "live: the 'none' outcome on the clean qa_ds_probe + (if a legacy row was planted) the bind outcome and its post-migration redacted read; otherwise the unit pin packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts (plan outcomes: bind / drop-inline / none / refuse — datasource-credential-migration.ts:89-102; route: admin-routes.ts:587-609)", + "evidence": "the migrate responses + the post-migration detail read (or the unit-test output for the bind arm)" }, { "clause": "a runtime create lands with provenance + health: POST /api/v1/datasources creates a sqlite-file datasource (201) and the subsequent list shows it with origin:'runtime' and a health status", @@ -1106,14 +1141,17 @@ "evidence": "the unit-test output (or the 503 response on a deliberately unwired boot)" }, { - "clause": "unknown name → 404 RESOURCE_NOT_FOUND; and (FINDING) the /api/v1/datasources admin CRUD is UNLEDGERED — absent from packages/rest/src/rest-route-ledger.ts (only the /datasources/:name/external/* federation routes are ledgered there), a tranche-3 route-ledger discipline gap the run must record (PENDING-GAPS §E)", + "clause": "unknown name → 404 RESOURCE_NOT_FOUND; and the family IS ledgered — the rev-1 'unledgered tranche-3 gap' finding is CLOSED and must NOT be re-filed: the admin CRUD carries its own audited ledger (datasource-route-ledger.ts, 11 rows, all deliberately server-only — no SDK method reaches this family, filed as #7954) with a conformance test deriving both directions from the registrar, precisely because the mount style (registerDatasourceAdminRoutes straight on IHttpServer from serve.ts) is invisible to the dispatcher and REST ledgers", "oracle": "api", - "verify": "GET /api/v1/datasources/does-not-exist → 404 RESOURCE_NOT_FOUND; run record notes the admin routes carry no route-ledger entry", - "evidence": "the 404 + the unledgered-mount finding" + "verify": "GET /api/v1/datasources/does-not-exist → 404 RESOURCE_NOT_FOUND; the ledger + gate at packages/services/service-datasource/src/datasource-route-ledger.ts (DATASOURCE_ROUTE_LEDGER, :134-166) and datasource-route-ledger.conformance.test.ts — a run may cite the conformance test's pass as the parity evidence", + "evidence": "the 404 + the conformance-test output" } ], "negative": [ "the cleartext secret appearing in ANY list/detail response is a security FAIL — hasSecret is the only permitted signal", + "a 200 to the anonymous or no-capability caller on ANY of the 11 routes is a security FAIL (RUNNER rule 2's authz carve-out governs the report); conversely, re-filing the drivers-catalog 401 as 'static catalog broken' is a false finding — the floor is uniform by design", + "POST /datasources/test leaving a persisted row behind (or capturing 'test' as a datasource name) is a FAIL against the draft-probe contract", + "migrate-credential answering 400 for a row that is merely already-clean, or leaving the inline cleartext readable after reporting a bind, is a FAIL (#8155)", "a federation-route 503 that names datasource-admin (the service that IS running) instead of external-datasource is the #4225 mis-attribution regressed — FAIL", "a datasource-admin refusal carrying EXTERNAL_DATASOURCE_ERROR (or an external-datasource refusal carrying DATASOURCE_ADMIN_ERROR) is the #4249 code mis-attribution — FAIL" ], @@ -1126,12 +1164,13 @@ "ref": "packages/services/service-datasource/src/__tests__/admin-routes.test.ts (+ __tests__/envelope.conformance.test.ts) — pins route behavior + envelope; the LIVE-mount half is not pinned, drive os dev for it" }, "source": [ - "packages/services/service-datasource/src/admin-routes.ts (the nine routes; splitSecret keeps the secret out of the persisted draft; resolve()/badRequest() per-service attribution #4225/#4249; getDatasource credential-stripped + hasSecret)", + "packages/services/service-datasource/src/admin-routes.ts (the eleven routes; the uniform floor docblock :85-100 + requireDatasourceAdmin :404-417 + DATASOURCE_ADMIN_CAPABILITY :261 #9391/#9593; splitSecret keeps the secret out of the persisted draft :481-492; resolve()/badRequest() per-service attribution #4225/#4249; getDatasource credential-stripped + hasSecret; POST /test draft probe :625-638; POST /:name/migrate-credential :587-609)", "packages/services/service-datasource/src/driver-catalog.ts (static DRIVER_CATALOG, configSchema projected from spec #4410)", + "packages/services/service-datasource/src/datasource-route-ledger.ts (DATASOURCE_ROUTE_LEDGER — the family's own audited ledger, #7744; all rows server-only, SDK question filed as #7954) + datasource-route-ledger.conformance.test.ts (both directions derived from the registrar)", + "packages/services/service-datasource/src/datasource-credential-migration.ts:89-102 (CredentialMigrationPlan: bind / drop-inline / none / refuse with reason+remedy, #8155) + __tests__/datasource-credential-migration.test.ts (the plan pins)", "packages/spec/src/api/error-code-ledger.zod.ts (DATASOURCE_ADMIN_ERROR, EXTERNAL_DATASOURCE_ERROR under @objectstack/service-datasource)", "packages/spec/src/api/errors.zod.ts (HttpStatusErrorCodeMap: 503 SERVICE_UNAVAILABLE, 404 RESOURCE_NOT_FOUND)", - "packages/cli/src/commands/serve.ts (mounts registerDatasourceAdminRoutes at /api/v1/datasources by default — NOT in the REST route ledger, tranche-3 gap)", - "PENDING-GAPS §B/§E (#4225/#4249; service-datasource has no route ledger)" + "packages/cli/src/commands/serve.ts (mounts registerDatasourceAdminRoutes at /api/v1/datasources by default — a raw IHttpServer mount, which is WHY the family carries its own ledger rather than a rest-route-ledger row)" ], "history": [ { @@ -1145,6 +1184,12 @@ "date": "2026-08-20", "change": "scoped scan-functionality (扫描功能) sweep: the knownGap's claim that the federation service is 'intentionally NOT wired in the admin-lifecycle boot' went STALE — serve.ts:2966-2979 now wires ExternalDatasourceServicePlugin unconditionally on every os dev / os serve boot (and :2984-2993 wires createExternalValidationPlugin). Rewrote the knownGap to the wired truth, re-sited the 503 clause onto its unit pin (oracle api → test) with live scoring not-applicable on a stock boot so a working 2xx is never mis-scored as a regression, reworded step 7 to match, and pointed the wired-boot introspection happy path at the new integration-system.external-schema-introspection item", "ref": "claude/new-session-0pv25p" + }, + { + "revision": 3, + "date": "2026-08-30", + "change": "coverage sweep, two stale texts + three uncovered arms. STALE: the rev-1 'UNLEDGERED admin CRUD / tranche-3 discipline gap' finding clause and the matching source lines were FALSE at head — #7744 landed the family's own datasource-route-ledger.ts (11 rows, all server-only, SDK question filed as #7954) with a registrar-derived conformance test, so a run following the old text files a false finding; the clause now asserts the ledger + gate positively. STALE: 'always-available static catalog' for GET /drivers — since #9391/#9593 requireDatasourceAdmin guards all 11 routes (401 anonymous, 403 without manage_platform_settings, decided before any service resolution), so the wording now pins the floor and warns a runner off misreading the drivers 401. ADDED: the uniform-floor clause (both sides, per ADR-0124 D5), the POST /datasources/test draft-probe clause (no persistence; 'test' never captured as a name), and the POST /:name/migrate-credential clause (#8155 — bind/drop-inline/none/refuse plan outcomes, 200-not-400 for a row with nothing to re-home, cleartext gone after a bind; live bind arm needs a planted legacy row, unit pin as fallback). Personas widened for the floor probes", + "ref": "#sweep-2026-08-30" } ] }, @@ -1252,10 +1297,10 @@ }, { "id": "integration-system.external-schema-drift-gate", - "title": "Boot-time external-schema drift gate (ADR-0015 §5.2 Gate 2): onMismatch defaults to 'fail' and refuses boot naming the object and mismatched column; 'warn' proceeds logging the diff; 'ignore' is silent; a partial metadata read withholds the all-clear instead of faking one", + "title": "Boot-time external-schema drift gate (ADR-0015 §5.2 Gate 2): onMismatch defaults to 'fail' and refuses boot naming the object and mismatched column; 'warn' proceeds logging the diff; 'ignore' is silent; checkOnBoot:false skips a datasource with a loud info line (boot step only — the interval sweep is unaffected); a partial metadata read withholds the all-clear instead of faking one", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "cli", "personas": [ @@ -1269,7 +1314,8 @@ ], "knownGaps": [ "NO stock drift fixture exists — drift must be INDUCED, and only in a scratch copy: the fixture DB is re-provisioned in-sync each boot, and showcase_external deliberately declares onMismatch:'warn' (showcase-external.datasource.ts:40) precisely so drift can never brick the stock showcase. To induce: work in a scratch app/worktree copy, alter the fixture DB between boots (prefer a column TYPE change — the idempotent provisioning initObjects re-runs at every boot and may heal an added/dropped column; verify the induced drift survives the re-run before scoring) or bind a scratch federated object to a missing/mismatched table. Without an induced drift, the live arms of clauses 1-3 are blocked(fixture) and the unit pins are the fallback evidence", - "the 'fail'-DEFAULT arm additionally needs a datasource that OMITS external.validation.onMismatch (the `?? 'fail'` default, external-validation-plugin.ts:331; the metadata-read-failure catch at :333 also defaults 'fail') — stock showcase never exercises it live", + "the 'fail'-DEFAULT arm additionally needs a datasource that OMITS external.validation.onMismatch (the `?? 'fail'` default, external-validation-plugin.ts:668; an unreadable definition also lands there — the def loader swallows the read failure to undefined, :584-602) — stock showcase never exercises it live", + "INDUCE MEASURED drift, not an outage: only MEASURED diffs reach the onMismatch policy — an 'unreachable' row (remote or definition could not be read) warns loudly and CONTINUES boot by maintainer ruling 2026-08-23 (#11166, external-validation-plugin.ts:323-354), so breaking connectivity (deleting the fixture DB file, bad credentials) tests the wrong arm and a non-aborting boot there is correct, not a gate failure", "clause 5's partial-read arm (a degraded metadata loader) is not reasonably inducible live — it is scored via its pin (list-diagnosed-consumer-sweep.test.ts), declared here rather than pretending a loader-outage fixture exists" ] }, @@ -1278,15 +1324,15 @@ "in a scratch copy, induce a surviving drift (see knownGaps) under the stock 'warn' policy; boot; capture the '[external-validation] external schema drift' warn (datasource/object/diffs) and that boot COMPLETES", "same drift with the scratch datasource's external.validation.onMismatch OMITTED (and again with explicit 'fail'); boot; capture the abort — ExternalSchemaMismatchError, message 'Object '' does not match its remote table on datasource '':' plus per-diff lines naming the column", "same drift under onMismatch:'ignore'; boot; confirm completion with NO drift warn", - "set external.validation.checkOnBoot:false on the drifted scratch datasource and boot: the kernel:ready scan STILL runs (checkOnBoot has no runtime consumer) — record the declared≠enforced finding, do not score the scan-running as a bug", - "declare external.validation.checkIntervalMs on a datasource and confirm the armed-timer log '[external-validation] armed background drift check' (the event-emission arm is unit-pinned)", - "run the pins: packages/runtime/src/external-validation-plugin.test.ts and the '#6504 … boot gate' describe in packages/runtime/src/list-diagnosed-consumer-sweep.test.ts" + "set external.validation.checkOnBoot:false on the drifted scratch datasource (onMismatch 'fail' or omitted) and boot: since #13149 the gate HONOURS the skip — boot COMPLETES, that datasource's rows are dropped before any verdict, and the info line '[external-validation] boot schema validation SKIPPED for datasource(s) that set `external.validation.checkOnBoot: false` …' names it; a second in-sync datasource in the same boot is still judged (the gate is per-datasource)", + "declare external.validation.checkIntervalMs on a datasource and confirm the armed-timer log '[external-validation] armed background drift check' (the event-emission arm is unit-pinned); declare it TOGETHER with checkOnBoot:false and confirm the checker still arms — the skip is the boot step's only (maintainer ruling 2026-08-29)", + "run the pins: packages/runtime/src/external-validation-plugin.test.ts, packages/runtime/src/external-validation-checkonboot.test.ts (#13037/#13149 — skip honoured, per-datasource scope, unreadable-def validates, alias spellings rejected, drift checker still arms), and the '#6504 … boot gate' describe in packages/runtime/src/list-diagnosed-consumer-sweep.test.ts" ], "acceptance": [ { "clause": "the DEFAULT is 'fail' and it refuses boot: with drift present and onMismatch undeclared (or explicit 'fail'), boot aborts with ExternalSchemaMismatchError whose message names the object, the datasource, and the mismatched column per diff line (renderDiffMessage) — the highest-consequence branch here: a mis-typed federation must not come up serving garbage", "oracle": "log", - "verify": "the induced-drift boot's abort output against external-errors.ts:155-183; the default pinned at external-validation-plugin.ts:331 (and :333 — an unreadable datasource definition ALSO defaults 'fail'). Unit fallback: external-validation-plugin.test.ts 'throws … default (fail) policy' + 'defaults to fail when the datasource definition is unavailable'", + "verify": "the induced-drift boot's abort output against external-errors.ts:155-183; the default pinned at external-validation-plugin.ts:668 (resolveOnMismatch `?? 'fail'`; an unreadable datasource definition ALSO lands on it — the memoized def loader answers undefined on a failed read, :584-602). Only MEASURED diffs reach this throw (:355-366) — an 'unreachable' row warns and continues by ruling (#11166), see knownGaps. Unit fallback: external-validation-plugin.test.ts 'throws … default (fail) policy' + 'defaults to fail when the datasource definition is unavailable'", "evidence": "the boot abort log (or the unit-test output when the live arm is blocked(fixture))" }, { @@ -1302,10 +1348,16 @@ "evidence": "the (absence in the) boot log + completed-boot proof" }, { - "clause": "checkOnBoot is DECLARED-but-UNENFORCED — assert the actual behavior and record the gap: the spec declares validation.checkOnBoot defaulting true (datasource.zod.ts:313) and docs/liveness claim it gates the boot scan, but the plugin reads only onMismatch/checkIntervalMs (its DatasourceDef, external-validation-plugin.ts:124-133; no consumer of checkOnBoot exists under packages/ outside spec + test fixtures) — so with checkOnBoot:false the kernel:ready scan STILL runs. The run must verify still-runs and record the declared≠enforced finding (ADR-0049 enforce-or-remove shape) — neither re-filing 'checkOnBoot broken' without checking for an existing card, nor scoring the scan-running as a regression", + "clause": "checkOnBoot IS ENFORCED at the boot step — the rev-1 declared≠enforced finding is FIXED by #13149 (2026-08-29) and must not be re-filed: (a) a datasource with external.validation.checkOnBoot:false is dropped BEFORE any verdict — no onMismatch policy applies to it, a measured mismatch on it cannot abort boot, no unreachable-remote warning is raised for it, and its objects are not counted in the all-clear; (b) the skip is LOUD — one info line names the skipped datasource(s), says their federated objects were NOT gated, and says background drift checking is unaffected; (c) only an explicit false opts out: an absent key (schema default true), an unparseable/legacy row, or a definition the metadata service could not hand back all VALIDATE (the safe direction), and the misspellings checkonboot/validateonboot are parse-REJECTED with a did-you-mean, never folded", "oracle": "log", - "verify": "boot the drifted scratch fixture with checkOnBoot:false — the gate still warns/aborts per onMismatch; run record carries the finding", - "evidence": "the boot log + the recorded finding" + "verify": "boot the drifted scratch fixture with checkOnBoot:false — boot completes and the '[external-validation] boot schema validation SKIPPED …' info line names it (external-validation-plugin.ts:288-312; bootCheckEnabled `!== false` :655-661); a sibling datasource in the same boot is still judged. Unit pins: external-validation-checkonboot.test.ts (all five describes — skip, unchanged default, per-datasource scope, one parsed spelling, boot-step-only scope)", + "evidence": "the completed-boot log with the skip line (or the pin output where the live arm is blocked(fixture))" + }, + { + "clause": "the checkOnBoot skip is BOOT-STEP-ONLY, by maintainer ruling (2026-08-29): a datasource declaring both checkOnBoot:false and checkIntervalMs still gets its background drift checker armed — the two keys answer different questions ('gate my startup on this' vs 'watch this while I run'), and arming a watcher is not an opt back in to the boot gate", + "oracle": "test", + "verify": "external-validation-checkonboot.test.ts 'scope: the gate covers the BOOT STEP ONLY' ('checkOnBoot:false still arms the background drift checker it asked for'); the ruling pinned in source at external-validation-plugin.ts:380-386 (⛔ do not add a checkOnBoot condition to scheduleDriftChecks) — live: the armed-timer log line on the combined-keys boot", + "evidence": "the pin output + the armed-timer log line from the checkOnBoot:false boot" }, { "clause": "partial-read honesty (#6504): when the federated-object listing was degraded, the gate WITHHOLDS the clean all-clear and warns that it 'swept an INCOMPLETE object set … the onMismatch gate could not have fired for them' (external-validation-plugin.ts:113-121) — silence and nothing-to-find are never conflated; a throwing verdict probe reports 'could not be determined', never a fabricated failure; a complete read still gets the plain all-clear info line", @@ -1322,6 +1374,7 @@ ], "negative": [ "a drifted boot under the 'fail' default that comes up serving anyway is the highest-consequence FAIL of this item — the gate's whole purpose", + "a checkOnBoot:false datasource whose MEASURED mismatch still aborts boot — or a skip performed SILENTLY (no info line) — is a FAIL against the #13149 contract; equally, a checkOnBoot:false datasource whose armed checkIntervalMs watcher goes missing is a FAIL against the boot-step-only ruling", "an all-clear info line emitted while the metadata read was degraded is the #6504 conflation — FAIL", "an abort message naming the wrong datasource/object, or omitting the per-column diff, is a FAIL against renderDiffMessage", "a background drift check that throws or kills the process is a FAIL (drift past boot is observational by design)", @@ -1333,13 +1386,13 @@ ], "automated": { "kind": "unit", - "ref": "packages/runtime/src/external-validation-plugin.test.ts (fail-default / warn / ignore / default-when-unreadable + drift-event emission) + packages/runtime/src/list-diagnosed-consumer-sweep.test.ts:334 (#6504 withheld all-clear) — the LIVE boot-abort arm is not pinned; an induced-drift boot is required for it" + "ref": "packages/runtime/src/external-validation-plugin.test.ts (fail-default / warn / ignore / default-when-unreadable + drift-event emission) + packages/runtime/src/external-validation-checkonboot.test.ts (#13037/#13149 — the checkOnBoot gate, its scope, and the one-spelling rule) + packages/runtime/src/list-diagnosed-consumer-sweep.test.ts:334 (#6504 withheld all-clear) — the LIVE boot-abort arm is not pinned; an induced-drift boot is required for it" }, "source": [ - "packages/runtime/src/external-validation-plugin.ts:147-227 (Gate 2 kernel:ready validation; :188 runValidation; :224-226 the fail throw), :325-335 (resolveOnMismatch — `?? 'fail'` at :331, catch → 'fail' at :333), :78-122 (announceAllClear #6504; :113-121 the INCOMPLETE-sweep warn), :239-317 (background drift checks + external.schema.drift events)", + "packages/runtime/src/external-validation-plugin.ts:269-368 (Gate 2 kernel:ready runValidation; :288-312 the #13149 checkOnBoot gate + skip info line; :323-354 the #11166 unreachable carve-out — warn and continue, only measured diffs reach onMismatch; :366 the fail throw), :655-661 (bootCheckEnabled — explicit false only, safe direction), :663-669 (resolveOnMismatch — `?? 'fail'` at :668), :584-602 (memoized def loader; a failed read answers undefined → strict defaults), :105-168 (announceAllClear #6504; :141 the INCOMPLETE-sweep warn), :388-527 (background drift checks + external.schema.drift events; :380-386 the boot-step-only ruling ⛔)", "packages/spec/src/shared/external-errors.ts:155-183 (renderDiffMessage + ExternalSchemaMismatchError — datasource, object, per-column diffs)", "packages/cli/src/commands/serve.ts:2966-2993 (ADR-0015 federation block — ExternalDatasourceServicePlugin + createExternalValidationPlugin wired unconditionally, best-effort dynamic import)", - "packages/spec/src/data/datasource.zod.ts:301-318 (validation policy schema; checkOnBoot declared, default true — the declared≠enforced finding of clause 4; the liveness ledger note packages/spec/liveness/datasource.json claiming it 'gates the boot-time one' is contradicted by source)", + "packages/spec/src/data/datasource.zod.ts:301-318 (validation policy schema; checkOnBoot default true at :313, the checkonboot/validateonboot alias rows at :301-302 are REJECTION-path did-you-means, not folds — enforced since #13149, so declared = enforced here now)", "examples/app-showcase/src/system/datasources/showcase-external.datasource.ts:40 (stock policy onMismatch:'warn' — deliberate, per its own comment)", "docs/adr/0015-external-datasource-federation.md §5.2" ], @@ -1349,6 +1402,12 @@ "date": "2026-08-20", "change": "new item from the scoped scan-functionality (扫描功能) coverage sweep: the boot-time drift gate — a 'fail' DEFAULT that can refuse boot, the sweep's highest-consequence uncovered branch — had no checklist coverage. Fixture honesty declared up front: no stock drift fixture exists (stock showcase is in-sync AND deliberately 'warn'), so drift is induced in a scratch copy or the arms fall back to the unit pins. Also records clause 4's finding: checkOnBoot is declared in spec (default true) but read by no runtime code — the scan runs regardless", "ref": "claude/new-session-0pv25p" + }, + { + "revision": 2, + "date": "2026-08-30", + "change": "coverage sweep: the rev-1 checkOnBoot declared≠enforced finding is FIXED by #13149 (issue #13037, landed 2026-08-29) — a run following the old clause 4 would file a false finding and mis-score the skip as a bug. Flipped to positive assertions: (a) checkOnBoot:false rows are dropped before any verdict with the loud SKIPPED info line (external-validation-plugin.ts:288-312), per-datasource and explicit-false-only (bootCheckEnabled :655-661; unreadable/absent validates — safe direction; checkonboot/validateonboot are rejection-path did-you-means, one spelling one read); (b) a NEW clause pins the boot-step-only scope by maintainer ruling 2026-08-29 — checkIntervalMs watchers still arm for a checkOnBoot:false datasource (:380-386). Also folded in the #11166 unreachable carve-out (measured diffs only reach onMismatch; an outage warns and continues, ruling 2026-08-23) so an induced 'drift' via broken connectivity is not mis-scored against the fail default; re-anchored every rotted line ref (runValidation :269-368, resolveOnMismatch :663-669, announceAllClear :105-168, drift checks :388-527) and added the external-validation-checkonboot.test.ts pin", + "ref": "#13149" } ] }, @@ -1554,6 +1613,215 @@ "ref": "#9299" } ] + }, + { + "id": "integration-system.connector-auth-kind-application", + "title": "Declarative connector static auth kinds (none/api-key/basic/bearer) resolve credentialRef at materialization and land on the wire in exactly their header/query shape; an unresolvable credentialRef is boot-fatal with the env-var prescription; oauth2 has NO authorable spelling (EE tier)", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "fixtures": { + "app": "showcase", + "requires": [ + "the rest provider factory installed (ConnectorRestPlugin in examples/app-showcase/objectstack.config.ts) — the generic static-auth executor this item drives; the openapi provider shares the same transport (openapi-provider.ts:182-189) and is not re-proven per kind", + "an ECHO upstream on the run's own port range that reflects request headers AND query back in its response body (same local-receiver pattern as webhook-lifecycle's fixture) — the showcase's self-ping connectors prove reachability only", + "env vars set for each probe's credentialRef before boot (the open tier resolves credentialRef from environment variables — plugin.ts:1798-1806)" + ], + "knownGaps": [ + "NO shipped connector declares a secret-bearing auth kind — all three showcase instances are auth:{type:'none'} on purpose (examples/app-showcase/src/system/connectors/index.ts:66,103,142; its own comment says a real upstream would use bearer+credentialRef). The per-kind instances are therefore scratch-authored in a writable package against the echo upstream; without the echo upstream the wire-shape clauses are blocked(fixture) and fall back to the unit pins (rest-connector.test.ts drives applyAuth through a stubbed fetch)" + ] + }, + "variants": [ + "none → no auth artifact added to the request (control)", + "bearer → Authorization: Bearer (rest-connector.ts:83-85)", + "basic → Authorization: Basic base64(username:password) — username authored in metadata (not a secret), password resolved from credentialRef (rest-connector.ts:86-90; connector-auth.zod.ts:143-150)", + "api-key, header form → header [headerName ?? 'X-API-Key']: (rest-connector.ts:91-93; default applied at resolution, plugin.ts:1811-1817)", + "api-key, query form → paramName rides the query string and the header is NOT set (rest-connector.ts:92)", + "rejection: auth type 'oauth2' — not an arm of ConnectorInstanceAuthSchema (none/bearer/api-key/basic only, connector-auth.zod.ts:157-162); OAuth2 token acquisition is the enterprise tier (ResolvedConnectorAuth doc, connector-auth.zod.ts:90-101)", + "rejection: inline `authentication` with any non-'none' type on an authored entry — the #7990 publish refusal (connector.zod.ts:935-941)", + "NOT enumSource-pinnable: the four static kinds are arms of a discriminated union of object schemas, not a named z.enum export — hand-enumerated here (4), guarded by the rejection variants instead" + ], + "steps": [ + "start the echo upstream; export the probe env vars (e.g. QA_BEARER_TOK, QA_API_KEY, QA_BASIC_PW); author five scratch rest-provider instances against it in a writable package: auth none / {type:'bearer',credentialRef:'QA_BEARER_TOK'} / {type:'basic',username:'qa',credentialRef:'QA_BASIC_PW'} / {type:'api-key',credentialRef:'QA_API_KEY'} / {type:'api-key',credentialRef:'QA_API_KEY',paramName:'api_key'}; boot", + "dispatch each instance's `request` action (flow connector_action or direct engine dispatch) at an echo path; capture what the upstream received per instance", + "read the authored rows back over GET /api/v1/meta and GET /api/v1/automation/connectors; grep both full bodies for every resolved secret value", + "unset QA_BEARER_TOK and boot again; capture the boot failure verbatim", + "author one instance with auth {type:'oauth2', ...} and one catalog/instance entry with inline authentication {type:'api-key', key:'x'}; build both; capture the two rejections", + "run the pins: packages/connectors/connector-rest/src/rest-connector.test.ts (wire shapes) and packages/services/service-automation/src/connector-materialization.test.ts (credentialRef resolution, env fallback, boot-fatal missing ref)" + ], + "acceptance": [ + { + "clause": "each static kind lands on the wire in exactly its shape, one verdict per variant, none inferred: bearer → Authorization: Bearer ; basic → Authorization: Basic base64(username:password); api-key header form → X-API-Key (or the authored headerName); api-key query form → the key in the named query param AND no key header; none → no auth artifact", + "oracle": "network", + "verify": "the echo upstream's captured headers/query per instance against applyAuth (packages/connectors/connector-rest/src/rest-connector.ts:75-96); unit fallback per shape in rest-connector.test.ts:103-138", + "evidence": "the five echoed requests, keyed by kind" + }, + { + "clause": "the secret reaches the wire through RESOLUTION, never through metadata: the authored row carries only the credentialRef name, and the dispatched request carries the env var's VALUE — proving materialization dereferenced the ref (resolveInstanceAuth builds the ResolvedConnectorAuth the factory receives, plugin.ts:1789-1821; ADR-0097 §3)", + "oracle": "api", + "verify": "the sys_metadata/meta read shows the ref string, the echo capture shows the resolved value; the two must differ and correspond", + "evidence": "the meta read + the matching echo capture" + }, + { + "clause": "an unresolvable credentialRef is a HARD BOOT ERROR carrying the prescription — the message names the connector, the provider, the ref, and both remedies ('set the env var, or wire AutomationServicePluginOptions.credentialResolver to a secrets service (ADR-0097 §3)') — an app must not come up with a connector whose credentials never loaded", + "oracle": "log", + "verify": "the unset-env boot aborts with the plugin.ts:1800-1805 message; pinned by connector-materialization.test.ts:330 ('fails boot loudly when credentialRef does not resolve')", + "evidence": "the fatal boot output" + }, + { + "clause": "oauth2 is refused at AUTHORING, not degraded at runtime: auth {type:'oauth2'} fails parse (invalid discriminator — ConnectorInstanceAuthSchema has exactly the four static arms; the EE-tier boundary is documented on ResolvedConnectorAuth), and an inline `authentication` with a non-'none' type is refused with the located #7990 message steering to auth:{type,credentialRef} — there is no door through which an OAuth2 flow or an inline secret enters open-edition metadata", + "oracle": "build", + "verify": "the two build rejections against connector-auth.zod.ts:157-162 (union arms) and connector.zod.ts:931-941 (the superRefine message, quoted fragments verbatim)", + "evidence": "the two error texts" + }, + { + "clause": "no resolved secret ever appears in a served body: GET /api/v1/automation/connectors descriptors and every /meta read of the scratch entries are free of all three secret values (grep the FULL bodies) — basic's username may appear (schema-declared non-secret), its password must not", + "oracle": "api", + "verify": "grep of the full listing + meta bodies for each env-var value", + "evidence": "the grepped bodies (secret-absent)" + } + ], + "negative": [ + "a resolved secret appearing in any listing/meta/descriptor body is a cleartext-credential FAIL of P0 severity regardless of this item's priority", + "an oauth2-authored entry building clean — or an inline non-'none' `authentication` publishing — is a FAIL against the #7990/ADR-0097 refusal doors", + "a missing credentialRef materializing a connector that fails only at dispatch time (instead of failing boot) is a FAIL against the hard-boot-error contract" + ], + "traps": [ + "stale-dist" + ], + "automated": { + "kind": "unit", + "ref": "packages/connectors/connector-rest/src/rest-connector.test.ts (per-kind wire shapes through a stubbed fetch) + packages/services/service-automation/src/connector-materialization.test.ts (credentialRef resolution incl. env fallback and the boot-fatal missing ref) — the LIVE echo-upstream leg is not pinned" + }, + "source": [ + "packages/spec/src/shared/connector-auth.zod.ts:78-101 (ConnectorAuthConfigSchema — the runtime shape; ResolvedConnectorAuth = the static open tier, OAuth2 named as the enterprise tier), :117-164 (ConnectorInstanceAuthSchema — the authored shape: credentialRef instead of inline secrets, four arms, no oauth2)", + "packages/spec/src/integration/connector.zod.ts:931-941 (the #7990 inline-`authentication` publish refusal, both message variants)", + "packages/services/service-automation/src/plugin.ts:1789-1821 (resolveInstanceAuth — credentialRef → ResolvedConnectorAuth; empty resolution throws with the env-var prescription :1800-1805; api-key headerName default applied :1815)", + "packages/connectors/connector-rest/src/rest-connector.ts:75-96 (applyAuth — the wire shapes per kind) + rest-provider.ts:52-54 (the factory receives the RESOLVED static subset)", + "packages/connectors/connector-openapi/src/openapi-provider.ts:182-189 (the openapi provider shares the same static-auth transport)", + "examples/app-showcase/src/system/connectors/index.ts:42-43,66,103,142 (all shipped instances auth none; the fixture's own pointer at bearer+credentialRef for real upstreams)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new item from the 2026-08-30 coverage sweep (spec-enums angle): the static connector-auth tier had NO checklist coverage — connector-declarative-boot proves the auth-less happy path and the inline-secret refusal, but no item proved a resolved credential actually lands on the wire per kind, the credentialRef boot-fatal contract, or the oauth2 no-authorable-spelling boundary. Register hypothesis CORRECTED against source: the register said oauth2 'declared in open edition degrades loudly' — it does not reach runtime at all; ConnectorInstanceAuthSchema simply has no oauth2 arm, so it is a parse rejection at authoring (and the inline `authentication` spelling is the #7990 publish refusal) — the clause asserts refusal-at-build, not a runtime degrade. Not enumSource-pinnable (discriminated union, no named z.enum) — hand-enumerated 4 static kinds and said so in variants", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "integration-system.external-schema-browser-ui", + "title": "Setup → Datasources federation UI (ADR-0015 §6.4): SchemaBrowser mirrors the remote-table listing, ImportObjectDialog lands a REAL object through the metadata channel, Refresh catalog and ValidationPanel drive the three previously-uncovered federation routes (import / refresh-catalog / validate) behind their capability floors", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin — authenticated, holding manage_platform_settings (federation reads: tables/validate) AND manage_metadata (federation writes: import/refresh-catalog, and the /meta write the UI import performs)", + "a second authenticated persona holding NO capability, plus an anonymous probe (for the floor clause)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped read-only SQLite external datasource showcase_external + its boot-provisioned fixture DB (examples/app-showcase/src/system/datasources/ — customers/orders, 5 columns each; same fixture as external-schema-introspection)", + "the built console mounted (the panel lives in the app-shell metadata-admin engine)" + ], + "knownGaps": [ + "the federation-unavailable 503 arm is NOT reachable on a stock boot (serve.ts:2966-2979 wires ExternalDatasourceServicePlugin unconditionally — same bound as datasource-admin-lifecycle's 503 clause); it needs a deliberately unwired boot", + "AND when that 503 IS reached, the UI's designed friendly state is EXPECTED-FAIL at head: objectui api.ts jsonOrThrow matches the RETIRED pre-#3843 body shape (`body.error === 'external_service_unavailable'`, api.ts:101-115, pinned by its own api.test.ts:65-70) while the server has answered the envelope `{ success:false, error:{ code:'SERVICE_UNAVAILABLE', message } }` since the sendError consolidation (external-datasource-routes.ts:383; response-envelope.ts:210) — so ExternalServiceUnavailableError cannot fire against a real head server and the 'Federation is not enabled on this server.' / SchemaBrowser 'unavailable' states are dead code live. Record the ACTUAL render (generic error text), never tick the friendly-hint clause green, and check for an existing objectui card before filing", + "the 'unreachable' diff kind (spec 2026-08-23, emitted by external-datasource-service.ts:789) is NEWER than the DIFF_LABEL map objectui compiles against: ValidationPanel.tsx:44-54 labels 9 kinds and lacks 'unreachable', so a validate run reporting an unreachable remote renders an unlabelled kind cell at head — the exact objectstack#4115 class the map's totality gate exists to catch; it will fail objectui's compile at the next spec pin bump (the panel's own comment documents the mechanism working that way for default_mismatch). Expected-fail if observed live; do not file as new without checking", + "diff-kind labels beyond the in-sync fixture need INDUCED drift — reuse external-schema-drift-gate's induced-drift recipe notes (scratch copy, measured column-type change); the stock in-sync fixture proves only the all-green render" + ] + }, + "steps": [ + "boot showcase isolated via os dev with the console; sign in as the entitled admin; pre-capture the API ground truth: GET /api/v1/datasources/showcase_external/external/tables and POST .../external/validate", + "navigate Setup → Datasources (the metadata-admin engine route …/metadata/datasource — the left-nav names it directly, objectui#3660/register.ts) and open showcase_external: the ExternalDatasourcePanel renders (DatasourcePreview.tsx:148-154 keys it off schemaMode !== 'managed') with the schemaMode badge and the read-only/writes-allowed pill; screenshot FIRST, then read the DOM", + "Tables tab: SchemaBrowser loads the remote tables (mount + explicit Refresh only — never a timer); compare the rendered list name-by-name and columnCount-by-columnCount against the pre-captured listing", + "header 'Refresh catalog': click it and capture the POST /api/v1/datasources/showcase_external/external/refresh-catalog on the network; the snapshot timestamp renders from the returned catalog.snapshotAt", + "Import: pick customers → ImportObjectDialog fires POST .../external/tables/customers/draft, renders the suggested name, any review flags, and the generated *.object.ts source; click 'Import as Object' and capture the PUT /api/v1/meta/object/ it performs (the UI's import door IS the metadata channel — api.ts:189-199 importObjectDraft); confirm the object now exists over GET /api/v1/meta and answers a read-only /data query against the external rows", + "the server-side import twin: POST /api/v1/datasources/showcase_external/external/tables/orders/import (a second, scratch import) → 201 { object }; confirm it too landed and is queryable; then delete/clean up both imported objects", + "Validation tab: run validation; capture POST .../external/validate → { ok, results } (URL-scoped work, #10537) and compare the rendered per-object verdict rows against it; on the in-sync stock fixture expect all-green; if an induced-drift scratch copy is available, confirm each reported diff kind renders with its DIFF_LABEL text", + "the floor, both sides: replay tables + validate as the no-capability session (403 PERMISSION_DENIED naming manage_platform_settings) and import + refresh-catalog as the same session (403 naming manage_metadata — FEDERATION_WRITE_CAPABILITY), plus one anonymous probe of each family (401 UNAUTHENTICATED, decided before any service lookup); also probe the UI import door's floor: the no-capability session's PUT /api/v1/meta/object/ refuses ('Rewriting stored metadata requires the `manage_metadata` capability')", + "ONLY on a deliberately unwired boot (see knownGaps): drive the Tables tab into the 503 and record what the UI ACTUALLY renders — expected-fail against the designed 'Federation is not enabled on this server.' state" + ], + "acceptance": [ + { + "clause": "the panel renders for a FEDERATED datasource only: showcase_external (schemaMode external/validate-only) shows the External Datasource panel with Tables + Validation tabs and the read-only pill; a managed datasource shows no such panel — and an unsaved draft shows the save-first guidance instead of firing name-less REST calls", + "oracle": "screenshot", + "verify": "the panel on showcase_external vs its absence on a managed datasource (DatasourcePreview.tsx:145-154; the unsaved-draft guard ExternalDatasourcePanel.tsx:76-85)", + "evidence": "the two screenshots (+ the unsaved-draft state if a wizard draft is on hand)" + }, + { + "clause": "SchemaBrowser mirrors the API exactly: the rendered table list equals GET .../external/tables name-for-name with per-table columnCount (customers/orders, 5 each on the stock fixture) — the network response is the authority, not the panel; data loads on mount + explicit Refresh only, never a timer", + "oracle": "dom", + "verify": "screenshot first, then DOM list diffed against the pre-captured listing (SchemaBrowser.tsx — listRemoteTables on mount/refresh; the fixture DDL in external-fixture.ts)", + "evidence": "screenshot + API read, diffed" + }, + { + "clause": "the import chain lands a REAL object through the metadata channel: the dialog's draft (POST .../tables/:remote/draft) renders name + review flags + generated source, and 'Import as Object' persists via PUT /api/v1/meta/object/ (api.ts:189-199) — after which the object exists in /meta, is bound to showcase_external, and answers a federated read-only /data query; the server-side twin POST .../tables/:remote/import (201 { object }, refusals 400 EXTERNAL_IMPORT_ERROR) lands the same way — two doors, one metadata channel, both behind the manage_metadata floor", + "oracle": "api", + "verify": "the captured PUT + the post-import GET /meta and /data reads; the twin route at external-datasource-routes.ts:449-477; the dialog flow in ImportObjectDialog.tsx (draft → review → PUT)", + "evidence": "the network captures + the post-import meta/data reads" + }, + { + "clause": "Refresh catalog round-trips: the header button fires POST .../external/refresh-catalog (a federation WRITE — manage_metadata floor), and the returned catalog.snapshotAt renders as the snapshot timestamp", + "oracle": "network", + "verify": "the captured POST + the rendered timestamp (ExternalDatasourcePanel.tsx:56-74,108-123; route at external-datasource-routes.ts:479-495)", + "evidence": "the network capture + the header screenshot" + }, + { + "clause": "ValidationPanel reports what the server measured: the run fires POST .../external/validate (URL-scoped to this datasource, #10537 — a read, manage_platform_settings floor) and renders one verdict row per federated object matching { ok, results }; every diff kind rendered carries its DIFF_LABEL text — with the head-version caveat that 'unreachable' is not yet in objectui's label map (knownGaps: expected-fail, the #4115 class; the map's totality over the imported spec union is the standing guard)", + "oracle": "dom", + "verify": "the rendered rows against the captured response (ValidationPanel.tsx:44-54 DIFF_LABEL; route at external-datasource-routes.ts:508-526; kinds at external-errors.ts:102-141 — 10 at head, 9 labelled in objectui)", + "evidence": "the response + the rendered rows (and the unlabelled-kind capture if drift/outage was induced)" + }, + { + "clause": "the floor holds on BOTH sides for all three newly-covered routes: anonymous → 401 UNAUTHENTICATED before any service lookup (#9686 shouldDenyAnonymous); the no-capability session → 403 PERMISSION_DENIED naming manage_platform_settings on validate (read) and manage_metadata on import/refresh-catalog (write, FEDERATION_WRITE_CAPABILITY); and the UI's own import door (PUT /meta/object) refuses the same session server-side — the dialog's success path must be unreachable for an unentitled operator by anything but pixels", + "oracle": "api", + "verify": "the probes of step 8 against refuseFederationRequest (external-datasource-routes.ts:287-324 — 'read' for validate at :513, 'write' for import/refresh at :458/:485) and the /meta manage_metadata gate (rest-server.ts:4270-4275)", + "evidence": "the 401/403 responses, keyed by route and persona" + }, + { + "clause": "federation-unavailable honesty (EXPECTED-FAIL at head, record — do not tick): the server's 503 names the external-datasource service in the envelope; the UI was DESIGNED to map it to a friendly 'Federation is not enabled on this server.' / 'unavailable' state, but its detector matches the retired pre-#3843 string body, so at head the real envelope falls through to the generic error path — the run records the actual render and the mismatch, and must not score the generic error as the friendly state working", + "oracle": "dom", + "verify": "on a deliberately unwired boot: the 503 envelope (external-datasource-routes.ts:383) vs the UI detector (api.ts:101-115, ExternalServiceUnavailableError) and the stale pin (api.test.ts:65-70); on a stock boot this clause is not-applicable-live", + "evidence": "the 503 response + the actual rendered state, side by side" + } + ], + "negative": [ + "a UI table/verdict row for anything the API did not report (or one missing) is a FAIL — the network response is the authority", + "the dialog reporting import success while no object row landed in /meta is a FAIL against the import chain", + "a 200 to the anonymous or no-capability caller on import / refresh-catalog / validate is a security FAIL (RUNNER rule 2's authz carve-out governs the report)", + "an imported object accepting DATA writes against the read-only external datasource is a FAIL (import must not grant writes — the datasource's allowWrites still governs)", + "a blank panel region or raw exception text where a designed state (loading / error / unavailable / save-first) should render is a FAIL" + ], + "traps": [ + "stale-console-bundle", + "hydration-race", + "wrong-persona", + "auth-state-leak" + ], + "source": [ + "objectui packages/app-shell/src/views/metadata-admin/external/api.ts:1-30 (the five-route client; :101-115 the retired-shape 503 detector; :131-199 listRemoteTables/generateObjectDraft/refreshCatalog/validateDatasource/importObjectDraft — the import door is PUT /api/v1/meta/object/:name)", + "objectui packages/app-shell/src/views/metadata-admin/external/{SchemaBrowser,ImportObjectDialog,ValidationPanel,ExternalDatasourcePanel}.tsx (the P5 surfaces; ValidationPanel.tsx:44-54 DIFF_LABEL total over the imported spec union — the #4115 recurrence guard) + previews/DatasourcePreview.tsx:145-154 (panel keyed off schemaMode !== 'managed')", + "objectui packages/app-shell/src/views/metadata-admin/datasource/register.ts:1-32 (Setup → Datasources = the engine route; left-nav names it directly, objectui#3660)", + "packages/rest/src/external-datasource-routes.ts:30-34 (the five-route family), :449-477 (import → 201 { object }, EXTERNAL_IMPORT_ERROR), :479-495 (refresh-catalog), :508-526 (validate, #10537 URL-scoped), :287-324 (refuseFederationRequest — read=manage_platform_settings, write=manage_metadata, #9686 anonymous floor), :383 (the 503 envelope)", + "packages/spec/src/shared/external-errors.ts:102-141 (SchemaDiffEntryKind — 10 kinds at head incl. index_mismatch/unmapped_index/default_mismatch/unreachable)", + "packages/services/service-datasource/src/external-datasource-service.ts:789 (the 'unreachable' producer)", + "sibling coverage this item deliberately does NOT duplicate: external-schema-introspection (tables/draft API contract + twin equivalence + auth-floor pins), external-datasource-federated-read (the query path), external-schema-drift-gate (boot gate; its induced-drift recipe notes are reused here for diff-label evidence)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new item from the 2026-08-30 coverage sweep (built-in-apps angle): the federation Studio UI and three of the five federation routes (import / refresh-catalog / validate) had no checklist coverage — external-schema-introspection covers only the two read twins. Grounded against source with two register corrections: (1) the UI's import door is PUT /api/v1/meta/object/:name (api.ts importObjectDraft), not the server's /external/tables/:remote/import route — both are covered as two doors into one metadata channel; (2) the register's 'federation-less boot surfaces the 503 as the named hint' is EXPECTED-FAIL at head: the UI's 503 detector matches the retired pre-#3843 string body while the server answers the sendError envelope, so the friendly state is dead code live (recorded as a knownGap + expected-fail clause, not asserted). Also recorded the 'unreachable' diff kind (2026-08-23) outrunning objectui's DIFF_LABEL pin — the #4115 class the map's compile-time totality will catch at the next spec bump", + "ref": "#sweep-2026-08-30" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index fd99fbb488..50c319ac11 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -8,7 +8,7 @@ "title": "Showcase boots clean: health + ready 200, no degraded startup banners, console + app metadata served", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P0", "surface": "mixed", "preconditions": [ @@ -64,7 +64,9 @@ "dogfood-verification skill §0–§1", "#3415", "packages/runtime/src/route-ledger.ts (GET /health, GET /ready)", - "examples/app-showcase/src/ui/apps/index.ts (authored nav)" + "examples/app-showcase/src/ui/apps/index.ts (authored nav)", + "objectui e2e/console-boot-indicator.spec.ts (objectui#2628 — the pre-bundle window paints a boot indicator, never a pure white page: fcp-before-React-mount ordering + scripts-blocked cause; a permanent pin adjacent to this item's console-shell clause, objectui-repo-only so not runnable from this checkout)", + "objectui e2e/console-rendering.spec.ts (production-build bootstrap renders without critical console errors + client-side routing works — the 'blank page' failure class; same objectui-repo-only caveat)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial — standing P0 smoke distilled from the dogfood boot protocol", "ref": "#3358" }, @@ -75,6 +77,12 @@ "date": "2026-08-21", "change": "scoped clause 2's ERROR-line grep to the boot window and said why. A caller-error refusal logs at ERROR level with a full stack before answering its 4xx — measured on 17.1.0 with a `$fn` filter probe answering 400 INVALID_FILTER — so any clause grepping the whole log for ERROR reads ordinary, correctly-refused 4xx traffic as a boot failure. The clause now names the window (process start to the first health 200, the instant clause 0 already records), prescribes cutting the log there or capturing it before the first request, and the negatives carry both directions so the false P0 is recognisable from either side. The security property is unchanged: a genuine ERROR-during-boot still fails independently of the probe (#10236 A5, measured #10257)", "ref": "#10236" + }, + { + "revision": 5, + "date": "2026-08-30", + "change": "source-ref addition (2026-08-30 sweep, console-UI angle): the two live objectui e2e specs adjacent to this item's console-shell clause were referenced nowhere in the ledger — e2e/console-boot-indicator.spec.ts (objectui#2628, the pre-bundle white-page pin) and e2e/console-rendering.spec.ts (bootstrap/rendering blank-page pin). Added as source entries with the objectui-repo-only caveat (RUNNER.md standing fact), NOT as automated.ref — they pin the console build, not this item's boot protocol, so a run here may cite them as adjacent evidence but is not asked to execute them. No step/clause change", + "ref": "#sweep-2026-08-30" } ] }, @@ -1633,6 +1641,969 @@ "ref": "#12438" } ] + }, + { + "id": "platform-core.marketplace-install-local-lifecycle", + "title": "Marketplace install-local: the wiring matrix mounts the right arms per OS_CLOUD_URL, the air-gapped inline install works with NO control plane, reseed/purge touch exactly the sample rows, every mutating door is manage_metadata-gated both sides, and the listing projects installedBy/storageDir per principal", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — admin_full_access carries manage_metadata)", + "a plain member (fresh runtime sign-up — member_default holds NO manage_metadata)", + "anonymous", + "operator (local shell — the `os package install` door)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "TWO isolated boots of the same app: boot A = plain `objectstack dev` with NO OS_CLOUD_URL (resolveCloudUrl() falls back to DEFAULT_CLOUD_URL — truthy — so the CLOUD arm mounts: proxy + install-local + cloud-connection + runtime-config); boot B = the same line with OS_CLOUD_URL=off exported (the offline arm: install-local pinned to the 'off' control plane + runtime-config ONLY — nothing that dials out mounts). The matrix is decided by Serve.planMarketplaceWiring, pure and testable (packages/cli/src/commands/serve.ts:1394-1444)", + "a compiled package artifact for the inline install: pnpm -C examples/app-crm build → dist/objectstack.json (the air-gapped path the #8343 fix exists for — `handleInstall`'s inline branch never reads the cloud URL)", + "admin credentials passed to the CLI door (--email/--password or OS_RUNTIME_EMAIL/OS_RUNTIME_PASSWORD) — the runtime refuses an unauthenticated install with a 401 that names exactly that remedy (packages/cli/src/commands/package/install.ts:198-201)" + ], + "knownGaps": [ + "the BROWSE leg (ALL /api/v1/marketplace/* passthrough, MarketplaceProxyPlugin) forwards to the public control plane with no credentials — on an egress-blocked or air-gapped runner the upstream fetch fails and the leg scores blocked(environment), never a product FAIL. What this item scores about browse is the MOUNT (present on boot A, absent on boot B), which needs no egress", + "installing FROM the catalog (POST with packageId, no inline manifest) also needs the control plane → same blocked(environment) posture; the inline-artifact leg is the one this item proves end to end" + ] + }, + "steps": [ + "boot A (no OS_CLOUD_URL), as admin: GET /api/v1/runtime/config and record features.marketplace + features.installLocal; probe the mounts — GET /api/v1/marketplace/install-local (authed) → 200, and the browse namespace answers as a mounted passthrough (a non-404 that reflects the upstream's reachability, not this kernel's routing). Capture the boot banner's Marketplace plugin line", + "boot B (OS_CLOUD_URL=off), as admin: GET /api/v1/runtime/config → still served, now reporting installLocal:true AND marketplace:false (#8389 discovery + #8356 route-table derivation); GET /api/v1/marketplace/install-local → 200 []; a browse-namespace probe (e.g. GET /api/v1/marketplace/packages) → 404 (the proxy did NOT mount); GET /api/v1/cloud-connection/status → 404 (cloud-connection did NOT mount)", + "build the artifact: pnpm -C examples/app-crm build; confirm dist/objectstack.json exists", + "air-gapped install against boot B: `os package install ./examples/app-crm/dist/objectstack.json` with admin credentials — the CLI signs in, then POSTs the manifest inline to /api/v1/marketplace/install-local (install.ts:170); capture the success output and the installed entry", + "confirm the install registered: GET /api/v1/meta/app lists the CRM app; a CRM object answers on /api/v1/data; GET /api/v1/marketplace/install-local as admin shows the entry WITH installedBy and storageDir", + "per-principal projection (#9011): repeat the GET as the plain member — 200, the entry listed, but installedBy/storageDir ABSENT from the body; repeat anonymous — 401", + "reseed/purge on the installed entry: create ONE user-authored row in an installed CRM object and record its id; POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data as admin — capture {deleted, skipped, errors}; re-read: every manifest-declared seed id is gone, the user-authored row SURVIVES (the purge deletes only ids declared in the cached manifest's seed datasets — marketplace-install-local-plugin.ts:1239-1315); restart boot B against the same DB and confirm the purged rows do NOT come back (sampleDataPurged persisted on the ledger entry)", + "POST …/:manifestId/reseed-sample-data as admin — the sample rows return and sampleDataPurged flips back off", + "capability gate both sides (#8976): as the plain member, POST install-local (any body), DELETE …/:manifestId, POST reseed, POST purge — each 403 naming the manage_metadata requirement; anonymous on each → 401; the admin's same calls succeeded above", + "DELETE /api/v1/marketplace/install-local/:manifestId as admin → 2xx whose message documents that a restart is needed to fully unload (engine.registerApp is additive); restart → the CRM app no longer registers (GET /meta/app drops it)" + ], + "acceptance": [ + { + "clause": "the wiring matrix holds on both boots: boot A (default cloud URL) mounts browse proxy + install-local + cloud-connection + runtime-config; boot B (OS_CLOUD_URL=off) mounts install-local + runtime-config ONLY, with the install-local instance pinned to the 'off' control plane so its catalog branch answers a refusal instead of dialing the PUBLIC default cloud (Serve.OFFLINE_CONTROL_PLANE — resolveCloudUrl treats '' as unset and would substitute cloud.objectos.ai)", + "oracle": "api", + "verify": "the mount probes from steps 0-1: on boot B the browse and cloud-connection namespaces 404 while install-local answers 200 — the exact opposite of the pre-#8343 regression, where install-local was the one that 404'd under `off` (measured on objectos-ee 4.0.5-rc.1, whose compose file ships OS_CLOUD_URL:-off as the DEFAULT)", + "evidence": "the per-route status table for both boots + the boot banner lines" + }, + { + "clause": "runtime-config tells the TRUTH about this kernel on both boots: features.marketplace is DERIVED from the serving app's live route table (#8356) and features.installLocal is derived with the constructor option as a ceiling (#8388) — so boot B reports installLocal:true AND marketplace:false, and neither flag contradicts the same boot's own route probes", + "oracle": "api", + "verify": "cross-check each boot's GET /api/v1/runtime/config features against that boot's route-probe results from clause 0 — the flags and the routes must agree in all four cells (the payload/alias contract of the route itself is platform-core.runtime-config-boot-read's — do not double-score it here)", + "evidence": "the two runtime-config bodies beside the two route tables" + }, + { + "clause": "the air-gapped inline install works end to end with NO control plane: `os package install ./dist/objectstack.json` against boot B answers success, the manifest is cached to disk, and the package's apps/objects register and serve", + "oracle": "api", + "verify": "the CLI success output + GET /meta/app listing the CRM app + a data read on an installed object; the ledger row's own note is the ground: `handleInstall`'s inline branch never reads this.cloudUrl (cloud-connection-route-ledger.ts, POST /api/v1/marketplace/install-local)", + "evidence": "CLI transcript + the meta/app listing + one data read" + }, + { + "clause": "the listing is per-principal (#9011): admin sees installedBy + storageDir on each entry; an authenticated NON-manage_metadata member gets the entry WITHOUT those two fields; anonymous gets 401 — presence-preserving projection, not a hidden route", + "oracle": "api", + "verify": "the three GET /api/v1/marketplace/install-local bodies side by side; the member's body must still LIST the entry (a 403 or an empty list for the member is a different, wrong behavior)", + "evidence": "the three response bodies" + }, + { + "clause": "purge deletes EXACTLY the manifest-declared sample rows: every seed-dataset id gone, the user-authored row untouched, already-deleted ids counted as skipped, and the emptiness is DURABLE — sampleDataPurged persists so the rehydrate-time healer does not re-seed on the next restart", + "oracle": "api", + "verify": "the purge response counts + the post-purge reads (user row present, seed ids absent) + the post-restart re-read (still absent); reseed then restores them and flips the flag back (marketplace-install-local-plugin.ts:324-364 rehydrate guard, :1306-1311 flag write)", + "evidence": "purge response + before/after/post-restart row reads" + }, + { + "clause": "every MUTATING door (install POST, DELETE, reseed, purge) requires manage_metadata (#8976), proven BOTH sides: the admin's calls succeed, the plain member's answer 403 naming the capability, anonymous answers 401 — the same ADR-0066 D1 key the /meta write doors demand, deliberately not a new install-specific capability", + "oracle": "api", + "verify": "the 4×3 persona×door status matrix; run the member cells with the member's OWN session (auth-state-leak: prove the identity server-side before each cell)", + "evidence": "the status matrix with response codes/bodies" + }, + { + "clause": "DELETE removes the cached manifest and the response says a restart is needed to fully unload; after restart the package is gone from /meta/app — remove is honest about its restart coupling rather than pretending a hot unload", + "oracle": "api", + "verify": "the DELETE response text + the post-restart meta/app listing", + "evidence": "the DELETE body + the post-restart listing" + } + ], + "negative": [ + "install-local answering 404 under OS_CLOUD_URL=off is THE #8343 regression (the shipped EE default landed every self-hosted stack there) — P1, extract on sight", + "runtime-config advertising marketplace:true on a boot whose browse namespace 404s (or installLocal:true where the route is absent) is the #8356/#8388 declared-vs-derived gap re-opening", + "purge deleting a row the manifest's seed datasets do not declare is a data-loss FAIL — 'user-created records are never touched' is the handler's own contract", + "a 2xx for the plain member or anonymous on ANY mutating door is a FAIL of the #8976 gate — UI absence is not the gate, the server refusal is", + "purged sample rows reappearing after a restart is a FAIL of the sampleDataPurged rehydrate guard", + "a browse-leg upstream fetch failure on an egress-blocked runner filed as a product defect is a false finding — the mount is this kernel's contract, the upstream's reachability is not (score blocked(environment))" + ], + "traps": ["dispatcher-vs-hono-route", "absence-inference", "destructive-in-place", "wrong-persona", "auth-state-leak"], + "source": [ + "packages/cli/src/commands/serve.ts:1394-1444 (planMarketplaceWiring — the pure wiring decision; cloud arm vs offline arm, host-composed instances never replaced), :1225-1310 (INSTALL_LOCAL/RUNTIME_CONFIG/MARKETPLACE_PROXY/CLOUD_CONNECTION identities + RUNTIME_CONFIG_OPTIONS + OFFLINE_CONTROL_PLANE and why '' vs 'off' must differ), ~:2938-3080 (the §5 wiring block: #8343 install-local deliberately NOT gated on a cloud URL; #8389 offline runtime-config mount)", + "packages/cloud-connection/src/marketplace-install-local-plugin.ts (header contract :4-47; INSTALL_LOCAL_CAPABILITY = manage_metadata with the #8976 rationale :80-108; reseed/purge mounts :250-251; purge semantics :1239-1315; rehydrate skip on sampleDataPurged :324-364)", + "packages/cloud-connection/src/cloud-connection-route-ledger.ts:198-244 (the five install-local rows: POST/GET/DELETE + reseed-sample-data + purge-sample-data, each with its gate stated), the marketplace-proxy passthrough row, and the runtime-config rows", + "packages/cli/src/commands/package/install.ts:98-135 (inline artifact mode), :170 (the POST to /api/v1/marketplace/install-local), :198-201 (the 401 remedy)", + "#8343 (install-local off the cloud gate), #8389 (offline runtime-config discovery), #8356 (features.marketplace derived), #8388 (features.installLocal derived), #8976 (manage_metadata gate), #9011 (per-principal projection)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 coverage sweep from a FOUR-angle register hit (console-UI, routes, built-in-apps, docs-claims all found the surface untested). Split per the register's suggestion into this api-lane lifecycle item and platform-core.marketplace-console-honesty (browser). Grounding confirmed every load-bearing citation: planMarketplaceWiring's two arms and the 'off' vs '' control-plane distinction, the #8976 manage_metadata gate and #9011 projection in the plugin source, purge's exactly-the-declared-ids semantics with the sampleDataPurged rehydrate guard, and the CLI's inline-artifact door at install.ts:170", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.marketplace-console-honesty", + "title": "Marketplace console honesty: nav entries live and die with their capability, the disabled state renders the configuration conclusion (never a red 'failed to load'), Installed Apps drives the real same-origin lifecycle, and the admin guard is checked before runtime resolution", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a plain member (fresh runtime sign-up — member_default)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the same two boots as platform-core.marketplace-install-local-lifecycle (boot A default cloud arm, boot B OS_CLOUD_URL=off offline arm) — reuse them and its installed CRM artifact for the Installed Apps content", + "the marketplace routes are registered by DefaultAppContent in @object-ui/app-shell (system/marketplace, system/marketplace/:packageId), so they exist for every host console" + ], + "knownGaps": [ + "rendering the browse CATALOG (boot A) needs the public control plane reachable — on an egress-blocked runner the catalog leg is blocked(environment); the DISABLED state (boot B) is the leg this item can always prove, and it is the one #5504 was about" + ] + }, + "steps": [ + "boot B (OS_CLOUD_URL=off), as admin in Setup: confirm the group_apps nav carries Installed Apps (nav_marketplace_installed — the entry rides MARKETPLACE_INSTALLED_UI_BUNDLE, registered by the install-local plugin that IS mounted) and does NOT carry Browse Marketplace (nav_marketplace_browse rides the browse proxy, which is not mounted) — the marketplace-ui.ts ownership rule: 'the entry lives and dies with the capability → no dead page'", + "boot B: navigate directly to /apps/setup/system/marketplace — the page renders the MarketplaceDisabled card (data-testid marketplace-disabled): muted informational styling, a hint addressed to the operator naming the action, a Back-home button. Screenshot", + "boot B: open the Installed Apps page — the installed CRM entry renders with its manifest identity; drive reseed and purge from the page and capture the same-origin POSTs to /api/v1/marketplace/install-local/:manifestId/{reseed,purge}-sample-data on the wire", + "boot A (default), as admin: the group_apps nav now carries BOTH Browse Marketplace and Installed Apps plus Cloud Connection (nav_cloud_connection); open the browse page — if the control plane is reachable the catalog renders; if not, record blocked(environment) for the catalog leg only (the page mounting and asking is this kernel's part)", + "as the plain member on either boot: navigate directly to /apps/setup/system/marketplace — the ADMIN GUARD answers first (#5583 order: MarketplaceAccessDenied, not MarketplaceDisabled and not a load spinner), because access is decided before runtime-config resolution", + "on boot B reload the disabled page several times: confirm no transient red 'Failed to load' card paints before the disabled card settles (the objectui#5533/#5557 resolving-state work — MarketplaceResolving covers the gap)" + ], + "acceptance": [ + { + "clause": "nav ownership: on the offline boot, Installed Apps is present and Browse Marketplace is ABSENT from Setup's group_apps; on the cloud boot both are present (plus Cloud Connection) — each entry registered by the plugin that owns its capability, so no nav entry ever points at a dead page", + "oracle": "api", + "verify": "GET /api/v1/meta/app?id=setup on each boot and diff the group_apps items (nav_marketplace_browse / nav_marketplace_installed / nav_cloud_connection presence per boot); confirm the rendered sidebar matches", + "evidence": "the two nav payloads + sidebar screenshots" + }, + { + "clause": "the disabled state is a CONFIGURATION CONCLUSION, not a failure: features.marketplace === false renders the MarketplaceDisabled card — muted styling, operator-addressed hint, back action — never the destructive 'Failed to load marketplace' card and never inferred from a failed request (objectui#5504)", + "oracle": "screenshot", + "verify": "the boot-B marketplace page screenshot shows data-testid marketplace-disabled with informational styling; isMarketplaceEnabled() reads the server-pushed runtime config, so the card only appears when the server itself reported the capability absent", + "evidence": "the screenshot + the runtime-config body it rests on" + }, + { + "clause": "Installed Apps is a real console: it lists the locally installed entry and its reseed/purge affordances issue the same-origin install-local POSTs, whose effects the api-lane item verifies — capture the wire calls here, score the row-level effects there (no double-cover)", + "oracle": "network", + "verify": "the captured POSTs to /api/v1/marketplace/install-local/:manifestId/reseed-sample-data and purge-sample-data from the page's buttons, answering 2xx as admin", + "evidence": "the network trace + the page screenshot" + }, + { + "clause": "guard order (#5583): a non-admin hitting the marketplace URL gets the access-denied state BEFORE any runtime resolution — never the disabled card (which would leak configuration posture to a persona that may not read it) and never an indefinite spinner", + "oracle": "screenshot", + "verify": "the member's direct navigation renders MarketplaceAccessDenied on both boots; run as the member's own session (wrong-persona)", + "evidence": "the member screenshots on both boots" + }, + { + "clause": "no transient failure flash: reloading the disabled page never paints a red failure card in the window before the runtime config resolves (the resolving state covers it)", + "oracle": "screenshot", + "verify": "several fresh loads of the boot-B page, screenshot early and settled — only resolving → disabled, never a destructive card frame (objectui#5533/#5557)", + "evidence": "the load-sequence screenshots" + } + ], + "negative": [ + "the disabled runtime rendering 'Failed to load marketplace' (the pre-#5504 card, whose hint pointed operators back at the template that told them to set `off`) is the regression this item exists for", + "a Browse Marketplace nav entry on the offline boot is a dead page — a FAIL of the nav-ownership rule even if the entry renders a nice error", + "a member shown the disabled/configuration card instead of access-denied is a #5583 guard-order FAIL", + "ticking the disabled-state clause off a failed request (rather than the server-reported features.marketplace === false) is exactly the inference isMarketplaceEnabled() refuses to make — do not make it for the product" + ], + "traps": ["stale-console-bundle", "hydration-race", "absence-inference", "wrong-persona"], + "source": [ + "objectui packages/app-shell/src/console/marketplace/MarketplaceDisabled.tsx (the configuration-conclusion card, #5504 — its header states the whole design rationale), MarketplaceAccessDenied.tsx + MarketplacePackagePage.tsx:551 and MarketplacePage.tsx:210 (guard/disabled resolution order, #5583), MarketplaceResolving.tsx (the no-flash window, #5533/#5557), marketplaceApi.ts (isMarketplaceEnabled reads the server-pushed runtime config, never infers from failure)", + "packages/cloud-connection/src/marketplace-ui.ts (MARKETPLACE_BROWSE_UI_BUNDLE → nav_marketplace_browse owned by the proxy; MARKETPLACE_INSTALLED_UI_BUNDLE → nav_marketplace_installed + the marketplace_installed page owned by install-local)", + "packages/cloud-connection/src/cloud-connection-ui.ts (CLOUD_CONNECTION_NAV_CONTRIBUTIONS → nav_cloud_connection)", + "objectui apps/console/src/AppContent.tsx (marketplace routes registered by DefaultAppContent for every host)", + "platform-core.marketplace-install-local-lifecycle (the api-lane sibling this item's wire captures delegate their effects to)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — the browser half of the sweep's four-angle marketplace hit, split from the api-lane lifecycle item. Grounded against the objectui marketplace console: the #5504 MarketplaceDisabled configuration-conclusion card and its 'never inferred from a failed request' rule, the #5583 admin-guard-before-runtime-resolution order, the resolving-state no-flash window, and the framework-side nav bundle ownership (entries live and die with their capability)", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.runtime-config-boot-read", + "title": "Runtime config boot read: GET /api/v1/runtime/config answers 200 ANONYMOUSLY with cloud URL, capability flags, branding and telemetry posture; the legacy /api/v1/studio/runtime-config alias is byte-identical (same handler); nothing credential-shaped is in the body", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["anonymous", "seeded admin (control only)"], + "fixtures": { + "app": "showcase", + "requires": [ + "any stock isolated boot — a plain `objectstack dev` mounts RuntimeConfigPlugin through the marketplace wiring's cloud arm, and an OS_CLOUD_URL=off boot mounts it through the offline arm (#8389), so the route is served on BOTH stock shapes" + ] + }, + "steps": [ + "on a stock isolated boot, with NO session: GET /api/v1/runtime/config — capture status, headers and full body", + "confirm the payload shape: features (installLocal, marketplace, …), branding (productName, productShortName, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor), telemetry (errorReporting when configured), and the cloud/control-plane URL field", + "GET /api/v1/studio/runtime-config with the same (absent) auth — diff the two bodies byte for byte (the alias is mounted with the SAME handler instance; ledgered as its own row because it is its own wire path)", + "scan the serialised body for credential-shaped content: no session token, no secret handle plaintext, no Authorization material, no sys_secret ciphertext; telemetry.errorReporting.dsn, when present, is a PUBLIC client key by design — record its presence, it is not a finding", + "control: repeat the read WITH an admin session and confirm the payload is not silently enriched with anything the anonymous read withheld (the route is unauthenticated by construction — there is no per-principal projection to leak through)", + "repeat both reads on an OS_CLOUD_URL=off boot: still 200 anonymous, flags now reporting the offline truth (the flag/route agreement itself is scored by platform-core.marketplace-install-local-lifecycle clause 1 — here only that the route is served and anonymous on this boot shape too)" + ], + "acceptance": [ + { + "clause": "anonymous 200 with the documented payload: an unauthenticated GET /api/v1/runtime/config returns the features/branding/telemetry envelope — the SPA must read this BEFORE it can authenticate, so any auth gate here is a boot-breaking regression", + "oracle": "api", + "verify": "the no-session curl answers 200 with the payload keys; the ledger row records the deliberate posture ('grepped for a session/principal/401 gate in this plugin and there is none')", + "evidence": "the anonymous response, status + body" + }, + { + "clause": "the legacy alias is the same wire contract: GET /api/v1/studio/runtime-config answers byte-identically (same handler instance) with the same anonymous posture — older Studio bundles keep booting", + "oracle": "api", + "verify": "byte-diff of the two bodies fetched in the same instant; empty", + "evidence": "both bodies + the empty diff" + }, + { + "clause": "the route is served on BOTH stock boot shapes: the default cloud arm AND the OS_CLOUD_URL=off offline arm (#8389 — without the offline mount, a working install-local was undiscoverable and read as 'feature missing' from every UI)", + "oracle": "api", + "verify": "the anonymous 200 on each boot from steps 0 and 5", + "evidence": "the two responses with their boot identities" + }, + { + "clause": "nothing credential-shaped leaks: the body carries no session/token/secret material; the error-reporting DSN (a public client key) is the only key-like value and is there by design", + "oracle": "api", + "verify": "the scan of the serialised body from step 3; the admin-session control read from step 4 adds nothing withheld from anonymous", + "evidence": "the scanned body + the anonymous-vs-admin diff (empty)" + } + ], + "negative": [ + "a 401/403 on this route is a boot-breaking regression: the console reads it before any session exists — a client that must authenticate to learn where to authenticate is circular", + "the alias drifting from the canonical body (or being dropped) breaks older Studio bundles silently — the ledger keeps them as separate rows precisely so a move is reported", + "session, secret-store, or credential material in this anonymous body is a security finding — capture and cross-file to access-security, do not tick past it", + "flags asserted here that contradict the same boot's live route table belong to the marketplace item's clause 1 — do not double-file, cross-reference" + ], + "traps": ["dispatcher-vs-hono-route", "absence-inference", "cache-staleness"], + "source": [ + "packages/cloud-connection/src/runtime-config-plugin.ts (the payload contract in the header :6-22; features.marketplace derived from the live route table #8356 :146+; features.installLocal derived with the option as ceiling #8388 :165+; branding key resolution; telemetry.errorReporting #12681)", + "packages/cloud-connection/src/cloud-connection-route-ledger.ts:268-291 (both runtime-config rows: the anonymous-by-construction posture and why the alias is its own row)", + "packages/cli/src/commands/serve.ts (RuntimeConfigPlugin mounted on the cloud arm AND the offline arm — #8389; RUNTIME_CONFIG_OPTIONS shared by both on purpose so the arms differ only in what is mounted)", + "platform-core.marketplace-install-local-lifecycle (owns the flag-vs-route-table agreement; this item owns the route/alias/payload-hygiene contract)", + "api-backend.route-ledger-live-parity (samples GET /api/v1/runtime/config only for raw-app MOUNT parity and defers the marketplace/runtime-config depth to platform-core by its own words — the split is deliberate on both sides)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (routes angle): the anonymous boot-read the whole console SPA depends on had no item. Kept as its own item rather than clauses on the marketplace lifecycle item, split along the register's suggested seam: this item owns the route + alias + payload hygiene; the marketplace item owns the features-vs-live-routes agreement", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.docs-portal-render", + "title": "Docs portal render: /docs lists the installed corpus, a doc body renders with working intra-corpus links, a flat-doc URL redirects to its canonical in-book path, the per-app index scopes to one package, and a lesser persona's rendered tree equals the API's pruned subset — never a blank region", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a plain member (fresh runtime sign-up — member_default)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "an installed doc corpus — the showcase ships package docs (the same fixture platform-core.docs-audience-gate relies on); at least one doc with intra-corpus links and one book (or the implicit per-package book) so the tree has structure" + ], + "knownGaps": [ + "audience-gated cells (public / permissionSet books) may not exist in the stock corpus — the UI-mirror clause diffs whatever the API serves per persona, so it holds on the org-default corpus too; if the API-side item's gated cells are blocked(fixture), the corresponding UI cells are as well (mirror, never wider)" + ] + }, + "steps": [ + "as admin, open /docs: DocsLayout fetches book + doc once and shares it; the index (DocsIndex) renders the installed corpus grouped by book/package — screenshot first, then diff the rendered entries against GET /api/v1/meta/book + /api/v1/meta/doc", + "open a doc through the tree (BookSidebar → /docs//): the body renders; click an intra-corpus cross-reference — it resolves inside the portal viewer, not to a 404 or an external route", + "flat-doc permalink: navigate to /docs/ (one segment, not a book slug) — DocsSlug redirects to the canonical /docs// (every doc has a home book; identity stays single-coordinate per ADR-0046 §4/§6)", + "app-scoped index: open /apps//docs — AppDocsIndex lists ONLY that package's docs (grouping derives the package from the name's namespace prefix, doc-groups.ts); open one through it (/apps//docs/)", + "as the plain member: the sidebar Documentation entry is PRESENT (it lives in the base home items, visible to all users, not the admin cluster — UnifiedSidebar); open /docs and capture the rendered tree", + "UI-mirror diff: fetch the book tree(s) via the API as the SAME member and diff the member's RENDERED entries against the API's pruned subset — equal sets; then open every entry the member's tree offers: each resolves, none errors (the UI half of platform-core.docs-audience-gate clause 3 — a nav entry that refuses on click is the exact defect the per-entry filter exists to prevent)", + "screenshot-first throughout: any empty region is re-checked on a settled reload before being read as missing" + ], + "acceptance": [ + { + "clause": "the portal index renders the installed corpus: every book/doc the API serves for this persona appears in the rendered tree, and nothing rendered is absent from the API — an equal-set diff, not a spot check", + "oracle": "dom", + "verify": "after the screenshot confirms render, diff rendered entries vs GET /meta/book + /meta/doc (admin persona)", + "evidence": "the screenshot + the empty diff" + }, + { + "clause": "a doc body renders and intra-corpus links WORK: cross-references resolve to the portal's own viewer route", + "oracle": "screenshot", + "verify": "the rendered doc + a followed cross-link landing on the linked doc inside the portal", + "evidence": "both screenshots + the navigated URL" + }, + { + "clause": "a flat-doc URL redirects to its canonical in-book path: /docs/ → /docs// — the permalink shape keeps working after the book segment became derived nav", + "oracle": "network", + "verify": "the navigation trace shows the redirect and the canonical URL renders the doc", + "evidence": "the trace + the final URL" + }, + { + "clause": "the per-app index is scoped: /apps//docs lists only that package's docs, and its entries open in the app container viewer (ADR-0048)", + "oracle": "dom", + "verify": "the app-scoped listing diffed against the corpus filtered to that package's namespace prefix; one entry opened", + "evidence": "the listing diff + the opened doc" + }, + { + "clause": "the Documentation entry is visible to ALL personas (base home nav, not the admin cluster), and the lesser persona's rendered tree equals the API's pruned subset for that persona with every offered entry resolving — the UI mirrors the server's audience gate, neither wider nor narrower", + "oracle": "dom", + "verify": "the member's sidebar shows the entry; the member's rendered-tree vs member-API diff is empty; every member-visible entry opens without a 401/403 (run in the member's own session — auth-state-leak)", + "evidence": "member sidebar screenshot + the equal-set diff + per-entry open statuses" + }, + { + "clause": "no blank region anywhere in the portal walk: index, book landing, reader and app-scoped index each render content or a NAMED empty state", + "oracle": "screenshot", + "verify": "per-surface screenshots after settle; a persistent blank after reload is the finding", + "evidence": "the screenshot set" + } + ], + "negative": [ + "a rendered tree WIDER than the persona's API subset is an audience leak — cross-file to platform-core.docs-audience-gate and access-security rather than ticking any clause", + "a tree entry that 401/403s on click is a FAIL of the per-entry filter's UI mirror even though the tree itself rendered", + "a flat-doc URL answering 404 instead of redirecting breaks every historical permalink — FAIL", + "an empty index read once, right after navigation, is the hydration-race shape — screenshot-first, settle, re-read before any 'portal is empty' finding" + ], + "traps": ["hydration-race", "auth-state-leak", "stale-console-bundle", "wrong-persona"], + "source": [ + "objectui apps/console/src/App.tsx:249-257 (/docs → DocsLayout with index/:slug/:slug/:name children) + AppContent.tsx:35-41,176-185 (app-scoped /apps/:packageId/docs — AppDocsIndex + the same DocsLayout children)", + "objectui apps/console/src/pages/{DocsLayout,DocsIndex,DocsSlug,BookSidebar,AppDocsIndex}.tsx (DocsSlug: a non-book segment is a flat doc name, redirected to /docs//)", + "objectui apps/console/src/pages/doc-groups.ts (grouping by namespace prefix — the package is derived from the doc name, ADR-0046)", + "objectui packages/app-shell/src/layout/UnifiedSidebar.tsx:316-323 (the Documentation entry in the BASE home items — visible to all users by design, with the comment saying so)", + "ADR-0046 §6 (documentation spine; §6.2.1 derived membership; §6.4 implicit per-package book), ADR-0048 (package container docs)", + "platform-core.docs-audience-gate (the API-side gate this item's clause 4 mirrors — its clause 3 is the server half of the same contract)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep from a two-angle hit (console-UI + built-in-apps): the docs PORTAL UI had no item while its API-side gate (docs-audience-gate, v14.1) did. The load-bearing clause is the UI mirror: the lesser persona's rendered tree must equal the API's pruned subset and every offered entry must resolve — asserting the portal neither widens nor breaks the server's audience gate. since v16 by judgment: the portal predates the v17 window (the DocsLayout book-slug reshape is newer than the portal itself)", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.url-overlay-contract", + "title": "Reserved URL params (ADR-0054 C3): recordId drawer and form overlay push so Back closes them, tab/palette/shortcuts replace so Back never pages through chrome, formObject/formLink pre-link a child create, ?tab cold-loads, the from trail keeps a clickable path back, and a recordId+recordObject mismatch fails closed", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "stock showcase data: an object list whose records open in the drawer, a record detail with related-list tabs, and a subtable child (e.g. invoice lines) for the formObject/formLink leg" + ], + "knownGaps": [ + "the cross-object refusal's pin (objectui apps/console/src/components/FormPage.recordObject.test.tsx) lives entirely in the objectui repo — from this checkout it is neither runnable nor pin-evidenced (the standing 23-item objectui-pin fact in RUNNER.md); drive that clause by hand in the browser, or run the pin in an objectui checkout naming the revision" + ] + }, + "steps": [ + "record drawer: on an object list, open a record that renders in the drawer — the URL gains ?recordId= as a PUSHED entry; press browser Back — the drawer closes and the list is still there (one history entry consumed, no page change)", + "form overlay create: trigger a create that uses the global overlay — ?form=new pushes; fill nothing; Back closes the overlay; re-open and confirm ?form= (edit) behaves the same", + "child pre-link: from a parent record, create a subtable child via the overlay — capture ?form=new&formObject=&formLink=:; the FK field arrives pre-linked to the parent; RELOAD the URL cold — the pre-link survives (refresh-safe by construction: the state is the URL)", + "tab cold-load: copy a record URL, append ?tab=related (or a related: value), open in a fresh tab — the detail lands on that tab; now switch tabs in-page several times and press Back ONCE — you leave the record (or close the prior overlay), you do NOT step back through tab selections (tab REPLACEs)", + "palette/shortcuts chrome: open ?palette=1 and ?shortcuts=1 overlays, close them, and confirm Back never replays them (replace semantics — useUrlOverlay defaults to replace)", + "drill trail: from a record, drill into a related record — the URL carries from=…; the breadcrumb renders the ancestor trail and clicking a crumb navigates back to that level", + "cross-object refusal (#4292): take a real record id of object A; navigate to /forms/?recordId=&recordObject= — the route renders an ERROR state; capture the network: NO /data/ read was issued for the mismatched pair (the refusal precedes the read, so nothing is read and nothing can be written); control: the same URL with the AGREEING recordObject loads and prefills normally", + "case probe: repeat the mismatch with only a case difference in recordObject — still refused (object names are not case-folded)" + ], + "acceptance": [ + { + "clause": "user-opened overlays PUSH: the record drawer (?recordId) and the form overlay (?form=new/) each add one history entry and browser Back closes them, returning to the surface beneath", + "oracle": "screenshot", + "verify": "open → screenshot → Back → screenshot (overlay gone, list/record intact), for drawer and both form modes; URL bar captured at each step", + "evidence": "the before/Back screenshot pairs with URLs" + }, + { + "clause": "passive/chrome state REPLACEs: tab switches, the palette and the shortcuts dialog never stack history — one Back after N tab switches leaves the page rather than stepping through selections", + "oracle": "screenshot", + "verify": "N in-page tab switches + palette/shortcuts open-close, then a single Back — the resulting location proves no chrome entry was on the stack", + "evidence": "the switch sequence + the post-Back location" + }, + { + "clause": "?tab cold-loads: a fresh navigation to ?tab= lands directly on that tab (URL-addressable state, not just an in-page echo)", + "oracle": "screenshot", + "verify": "fresh-tab open of the copied URL shows the named tab active", + "evidence": "the cold-load screenshot" + }, + { + "clause": "formObject/formLink pre-link a child create and are refresh-safe: the FK arrives pre-linked to the parent, and a cold reload of the same URL reproduces the pre-linked form", + "oracle": "screenshot", + "verify": "the overlay's FK field shows the parent on first open AND after a cold reload of the captured URL", + "evidence": "both screenshots + the URL" + }, + { + "clause": "the from drill trail renders a clickable ancestor path and each crumb navigates to its level", + "oracle": "screenshot", + "verify": "the drilled record shows the trail; a crumb click lands on the ancestor", + "evidence": "trail screenshot + the crumb navigation" + }, + { + "clause": "a recordId whose recordObject names ANOTHER object fails CLOSED (#4292): error state, NO /data read issued for the mismatch, no form to submit — and the refusal is exact (case differences refuse too); the agreeing control loads normally", + "oracle": "network", + "verify": "the network trace for the mismatched URL shows zero /data/ reads for the pair; the error surface renders; the agreeing control's trace shows the normal load. Pinned in objectui by FormPage.recordObject.test.tsx — from this checkout, drive by hand (knownGaps)", + "evidence": "both traces + the error-state screenshot" + } + ], + "negative": [ + "Back paging through tab switches or re-opening the palette is a FAIL of the push-vs-replace rule — the registry's own table states which params push and which replace", + "a mismatched recordObject EDITING the same-id row of the wrong object is the silent-wrong-write #4292 closed — any /data read or write on the mismatch is a FAIL even if an error eventually renders", + "a page-scoped param reusing a reserved name (a view that repurposes ?recordId or ?form) is a contract violation — capture and file, the registry exists to make this a review-time offence", + "drawer/overlay state that survives only in memory (a cold reload of the captured URL losing the overlay or the pre-link) breaks URL-addressable state — FAIL" + ], + "traps": ["hydration-race", "automation-input", "shared-browser-tab"], + "automated": { + "kind": "unit", + "ref": "objectui: apps/console/src/components/FormPage.recordObject.test.tsx (the #4292 fail-closed matrix: mismatch → error state, no /data request, no write path, case-sensitive; agreeing control unchanged); objectui: packages/app-shell/src/hooks/__tests__/useUrlOverlay.test.tsx (replace semantics)" + }, + "source": [ + "objectui packages/app-shell/src/urlParams.ts:1-77 (the single registry of reserved params with the per-param push/replace column and the rule of thumb: user-opened overlays push so Back closes; passive selection and transient chrome replace so Back never pages through them; objectui#2269 P3, ADR-0054 C3)", + "objectui apps/console/src/components/FormPage.tsx (FORM_RECORD_OBJECT_PARAM :293; readFormRecordTarget create/edit/refuse :305-310 — recordObject can only ever refuse, never override; #4278/#4292)", + "objectui packages/app-shell/src/hooks/useUrlOverlay.ts (replace: true default for chrome overlays)", + "framework#2604 (form overlay D1/D2, formObject D3), objectui#2257 (stable tab values), objectui#4278 (?recordId on /forms), objectui#4292 (cross-object fail-closed)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (console-UI angle): the console's reserved-URL-param contract (ADR-0054 C3) had no item though it is the cross-route state contract every overlay and deep link rides. Grounded against urlParams.ts's own push/replace table and the FormPage #4292 fail-closed pin (objectui-repo-only, so the clause is drivable by hand per the RUNNER standing fact). since v16 by judgment from the issue numbers (#2269/#2604/#4278/#4292 pre-date the v17 window)", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.keyboard-shortcut-surface", + "title": "Keyboard shortcut surface: `?` and ?shortcuts=1 open the dialog and Esc closes it, typing in an input never triggers it, the WIRED accelerators fire (⌘K, ⌘/Ctrl+B, ⌘⇧I where AI is present) — and the five advertised-but-DEAD accelerators (⌘/, N, R, ⌘E, ⌘D) plus the B-vs-⌘B display mismatch are EXPECTED-FAIL probes, never ticked green", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "any stock boot with the console; an object list open for the dead-accelerator probes (N/R/⌘E claim record actions, so probe them where a record surface could plausibly respond)" + ], + "knownGaps": [ + "⌘⇧I (chat dock) is wired but the dock only exists when the AI surface is present — on a stock open boot the agent catalog is empty, the listener is not armed and ⌘⇧I is deliberately inert; that WHOLE cell (inert-when-gated AND armed-when-entitled) is scored by ai.console-ai-surface-gating — here record only that the accelerator's handler exists and is gated, never a second verdict", + "⌘⇧O / ⌘⇧S are advertised in the dialog's 'AI assistant' group but their handlers are PAGE-SCOPED to the AI chat page (matchAiChatShortcut in AiChatPage) — on a stock open boot that page is unreachable, so these two cells are blocked(fixture) on stock; on an AI-enabled deployment probe them ON the chat page and record the page-scope honestly" + ] + }, + "steps": [ + "press `?` on a settled console page (focus NOT in an input) — the shortcuts dialog opens (overlay data-testid overlay:keyboard-shortcuts) and the URL carries ?shortcuts=1; press Esc — it closes and the param clears", + "deep-link: open ?shortcuts=1 cold — the dialog is open on load (URL-addressable, ADR-0054)", + "input guard: focus a text input (e.g. a list filter), type `?` — the character lands in the input and NO dialog opens (the handler returns early for INPUT/TEXTAREA/contentEditable)", + "inventory the dialog: capture every advertised accelerator. At head the groups list: ⌘K palette / ? / Esc; B sidebar + ⌘/ focus-search; N create + R refresh + ⌘E edit; ⌘⇧O new chat + ⌘⇧S conversations; ⌘D dark mode", + "fire the WIRED set: ⌘K (or Ctrl+K) opens the command palette (depth of the palette itself belongs to search.console-global-search — here only that the accelerator fires); ⌘/Ctrl+B toggles the sidebar; ⌘⇧I is NOT probed here — its gated/entitled matrix belongs to ai.console-ai-surface-gating", + "display-mismatch probe: the dialog advertises bare `B` for the sidebar — press bare B (no modifier) outside any input: NOTHING happens (the handler requires metaKey||ctrlKey); press ⌘/Ctrl+B: the sidebar toggles. Record the mismatch as expected-fail", + "EXPECTED-FAIL probes, one by one on the object list, focus on the page body: ⌘/ (advertised focus-search), N (create record), R (refresh data), ⌘E (edit record), ⌘D (toggle dark mode) — for each, capture that NO corresponding effect occurs (no focused search box, no create overlay, no refetch on the wire, no edit mode, no theme class change). A repo grep across app-shell/console/components finds no handler for any of the five; these clauses record the actual behavior and MUST NOT tick green while the defect stands (defect K1 — filed by the sweep, not per run)", + "Esc closes the dialog from every open path; re-run `?` after the probes to confirm the dialog itself still works" + ], + "acceptance": [ + { + "clause": "the dialog's own contract holds: `?` opens it (guarded against input focus), ?shortcuts=1 deep-links it, Esc closes it, and the overlay carries its stable testid", + "oracle": "dom", + "verify": "after a screenshot confirms the overlay, assert overlay:keyboard-shortcuts present/absent across the open/close/deep-link/input-guard sequence", + "evidence": "the sequence screenshots + URL states" + }, + { + "clause": "the wired accelerators fire: ⌘K opens the palette and ⌘/Ctrl+B toggles the sidebar (⌘⇧I's gated matrix is ai.console-ai-surface-gating's — not re-scored here)", + "oracle": "screenshot", + "verify": "before/after screenshots per accelerator; drive keys with real keyboard events (automation-input — synthetic events that skip the window listener fake a dead shortcut)", + "evidence": "the per-accelerator screenshot pairs" + }, + { + "clause": "EXPECTED FAIL (defect K1a): the dialog advertises bare `B` for the sidebar while the only handler requires ⌘/Ctrl — bare B does nothing, ⌘/Ctrl+B works. The run records the mismatch; ticking this clause green requires the DISPLAY to be fixed to match the binding (or a bare-B handler to exist), at which point this item needs a revision", + "oracle": "dom", + "verify": "bare-B keypress leaves the sidebar state unchanged (read the sidebar's data-state), ⌘/Ctrl+B flips it; the dialog's Navigation group still lists bare B", + "evidence": "the sidebar state trace + the dialog screenshot" + }, + { + "clause": "EXPECTED FAIL (defect K1b): each of the five advertised accelerators ⌘/ (focus search), N (create), R (refresh), ⌘E (edit), ⌘D (dark mode) produces NO effect — no handler exists in the tree. Each probe is its own record: effect-absence proven against the specific advertised effect (focus, overlay, network refetch, edit surface, root theme class). A run must NOT tick these green while the shortcuts stay dead; when handlers land, flip the clauses to positive assertions in a revision", + "oracle": "network", + "verify": "per-key: the advertised effect's own oracle stays silent (no focus change via document.activeElement, no ?form=new push, no list refetch on the wire, no edit route, documentElement class list unchanged); reproduce twice on fresh loads per RUNNER rule 2", + "evidence": "the five per-key traces/DOM reads" + }, + { + "clause": "the advertised-but-page-scoped pair is recorded honestly: ⌘⇧O/⌘⇧S are listed globally but handled only on the AI chat page — on stock open boots blocked(fixture); on AI-enabled deployments they fire ON that page and do nothing elsewhere", + "oracle": "dom", + "verify": "keypress outside the chat page: no effect; on the chat page (where reachable): new-chat/list-toggle occur (matchAiChatShortcut)", + "evidence": "both contexts' traces, or the blocked(fixture) record on stock" + } + ], + "negative": [ + "ticking any advertised accelerator green off its LISTING in the dialog is the exact ticking-on-a-label failure this item exists to prevent — every accelerator is proven by firing it", + "a dead wired shortcut (⌘K or ⌘/Ctrl+B not responding) is a REAL fail, not an expected one — but rule out automation-input first: a synthetic event that does not reach the window/document listener fakes exactly this", + "if a future run finds the five dead shortcuts now WORKING, that is not a silent pass — the expected-fail clauses are stale and the item needs a revision flipping them to positive assertions (cite the fixing PR)", + "the `?` handler firing while an input is focused (swallowing typed text) would be a regression of the guard — probe it, it is cheap" + ], + "traps": ["automation-input", "stale-console-bundle", "absence-inference"], + "source": [ + "objectui packages/app-shell/src/chrome/KeyboardShortcutsDialog.tsx:38-77 (the advertised groups — the full inventory this item probes), :80-97 (the `?` handler with the input/textarea/contentEditable guard; `?` is the ONLY key it handles), :36 (?shortcuts=1 via useUrlOverlay)", + "objectui packages/app-shell/src/context/CommandPaletteProvider.tsx:56 (⌘/Ctrl+K — wired)", + "objectui packages/components/src/ui/sidebar.tsx:42 (SIDEBAR_KEYBOARD_SHORTCUT = 'b'), :115-127 (the handler requires metaKey||ctrlKey — the source of the B-vs-⌘B display mismatch)", + "objectui packages/app-shell/src/layout/chatDockState.ts:161-170 (matchChatDockShortcut — ⌘/Ctrl+Shift+I, composer-safe)", + "objectui packages/app-shell/src/console/ai/AiChatPage.tsx:~605-615 (matchAiChatShortcut — ⌘⇧O/⌘⇧S, PAGE-scoped)", + "repo-grep evidence for the five dead accelerators: no handler for ⌘/ / N / R / ⌘E / ⌘D anywhere in app-shell, console or components (the only near-misses are page-scoped: ApprovalsInboxPage's plain `r` quick-decide and useDebugMode's Ctrl+Shift+D — neither matches an advertised accelerator)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (console-UI angle). Grounding confirmed the register's defect hypothesis and widened it: the dialog advertises ⌘/, N, R, ⌘E and ⌘D with NO handler in the tree (expected-fail probes, defect K1 filed by the sweep), advertises bare B while the handler requires ⌘/Ctrl+B (display mismatch), and additionally advertises ⌘⇧O/⌘⇧S whose handlers are page-scoped to the AI chat page — recorded here as an honest page-scope cell the register had not listed. ⌘K and ⌘⇧I are wired (palette depth stays with search.console-global-search)", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.home-admin-cluster-links", + "title": "Home admin cluster + System hub: every shell-hard-coded Administration link and every hub card resolves to a real page (no RouteNotFound, no RecordDetailView for an object named 'system'), the five legacy system/* URLs redirect to their canonical object routes, hub counts reconcile against the API with honest error states, and a non-admin's home nav is Home + Documentation only", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — isWorkspaceAdmin)", + "a plain member (fresh runtime sign-up — member_default)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a stock boot — the cluster and hub are shell-hard-coded (not metadata), so no showcase-specific fixture; the hub counts need the seeded sys_user/sys_organization/sys_position/sys_permission_set/sys_audit_log rows any --seed-admin boot provides" + ], + "knownGaps": [ + "the pinning suites (objectui apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx and AppContent.legacyRedirects.test.tsx) live entirely in the objectui repo — not runnable from this checkout (the standing 23-item fact); the browser walk below is the from-here oracle" + ] + }, + "steps": [ + "as admin, open /home: the Administration cluster renders. Enumerate its entries against the source list — sys-settings → /apps/setup/system, sys-apps → …/system/apps, sys-marketplace → …/system/marketplace, sys-objects → /apps/setup/metadata/object, sys-datasources → /apps/setup/metadata/datasource, sys-users → …/system/users, sys-orgs → …/system/organizations, sys-roles → …/system/roles, sys-config → …/system/settings (9 entries; the two metadata-admin ones deliberately point at the canonical /metadata/:type routes, #3660/#3739)", + "click EVERY cluster entry: each lands on a real page — screenshot first, then assert no RouteNotFound, no 'No Apps Configured' empty state, and above all no RecordDetailView rendering for an object literally named 'system' (the historical failure: short words fell through to the record-id tail — objectui#3655's two measured landings)", + "open the System hub (/apps/setup/system): every card renders; click every card destination the same way. ⚠️ ONE carve-out: the 'AI Approvals' card is defect K2's subject — on an agent-less boot it renders ungated and its page polls a dead 501 endpoint; ai.console-ai-surface-gating owns that card's expected-fail scoring, so THIS walk records the observation and defers that single card's verdict there (never double-file K2)", + "legacy redirects: navigate directly to /apps/setup/system/users, …/organizations, …/roles, …/positions, …/permissions — each redirects to its canonical object route per the #3655 map (users→sys_user, organizations→sys_organization list, roles→sys_position [ADR-0090 D3 rename — 'Roles' and 'Positions' are one surface], positions→sys_position, permissions→sys_permission_set) and the landing page renders", + "counts reconcile: read the hub's five badges (Users / Organizations / Positions / Permission Sets / Audit Logs) and diff each against a direct API count of sys_user / sys_organization / sys_position / sys_permission_set / sys_audit_log AS THE SAME ADMIN", + "honest failure shape: confirm the badge semantics — a 404'd object renders 0; a non-404 failure (denied/offline) renders NO badge rather than a confident 0 (objectui#3679: null omits the badge; per-call, so one denied object costs only its own number)", + "as the plain member, open /home: the nav is Home + Documentation ONLY — no Administration cluster (isWorkspaceAdmin gate); the server-side half (Setup app refusal for the member) is platform-core.builtin-apps-nav-render clause 2/3 — cross-reference, do not re-score here" + ], + "acceptance": [ + { + "clause": "all 9 Administration entries resolve to real pages: no RouteNotFound, no dead fall-through to a RecordDetailView for 'system', no 'No Apps Configured' dead end (the #3590 fresh-env case: sys-settings targets /apps/setup/system, which resolves in BOTH the no-app and app-bearing branches)", + "oracle": "screenshot", + "verify": "per-entry screenshot-then-DOM walk; the entry list diffed against UnifiedSidebar's adminItems source so a grown/renamed entry is caught, not silently outside the walk", + "evidence": "the 9 per-entry screenshots + the entry-list diff" + }, + { + "clause": "every System hub card destination resolves the same way — these exact links dead-ended twice before (objectui#3655 history), which is why the whole surface is walked, not sampled; the 'AI Approvals' card alone is excluded from this clause's verdict (defect K2, scored by ai.console-ai-surface-gating)", + "oracle": "screenshot", + "verify": "per-card walk of SystemHubPage's destinations, the AI Approvals card recorded-not-scored", + "evidence": "the per-card verdict table" + }, + { + "clause": "the five legacy system/* URLs redirect per the #3655 map and land on rendering pages — bookmarks and the hub's own emitted URLs keep working; roles lands on sys_position (the ADR-0090 rename, one surface under two vocabularies), permissions on sys_permission_set (decision A: rules-and-assignments is layer 2)", + "oracle": "network", + "verify": "the navigation trace per legacy URL shows the redirect target object route; the landing renders", + "evidence": "the five redirect traces + landings" + }, + { + "clause": "hub counts reconcile against the API for the same principal, and the failure shape is honest: 404 → 0, any other failure → NO badge (null), never a confident wrong number — per object, so one denial costs one badge", + "oracle": "api", + "verify": "badge-vs-API diff for the five counts; if a count cannot be provoked into failing, score the honest-failure half from the rendered state only when a real failure occurs and record none-encountered otherwise", + "evidence": "the count table + the failure-shape note" + }, + { + "clause": "the member's home nav is Home + Documentation only — the Administration cluster is admin-gated in the shell, and the server refusal behind it is already proven by the builtin-apps item (both-sides split across the two items, neither double-scored)", + "oracle": "dom", + "verify": "the member's rendered home nav (own session — wrong-persona) contains exactly the two base entries", + "evidence": "the member nav screenshot + DOM excerpt" + } + ], + "negative": [ + "a cluster link or hub card landing on RouteNotFound or a RecordDetailView for 'system' is the exact regression class this item pins (it shipped twice) — FAIL and extract", + "a legacy system/* URL answering 404 instead of redirecting breaks every bookmark from the pre-slimming console — FAIL", + "a hub badge rendering 0 for a DENIED read (rather than omitting the badge) is the #3679 conflation — an administrator cannot tell 'none' from 'unknown' — FAIL of the honesty clause", + "the Administration cluster rendering for a non-admin is a client-courtesy leak; capture it AND check the server half (the member still cannot obtain setup) before deciding severity" + ], + "traps": ["wrong-persona", "hydration-race", "stale-console-bundle"], + "source": [ + "objectui packages/app-shell/src/layout/UnifiedSidebar.tsx:316-381 (homeNavigation: base Home + Documentation for everyone; the isWorkspaceAdmin-gated adminItems cluster with the #3590 /system targeting note and the #3660/#3739 canonical-metadata-route notes)", + "objectui apps/console/src/pages/system/SystemHubPage.tsx:100-160 (the five counts verified against the framework registry; sys_permission_set decided by #3655; null-on-error / 0-on-404 badge semantics, #3670/#3679)", + "objectui apps/console/src/AppContent.tsx:104-165 (SystemObjectRedirect — the five legacy URLs, the #3655 object map with the ADR-0090 roles→sys_position note, and the measured pre-fix landings) + the exported systemRoutes fragment", + "objectui apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx + AppContent.legacyRedirects.test.tsx (the objectui-side pins — see knownGaps)", + "platform-core.builtin-apps-nav-render (owns the server-side Setup refusal for the member — this item owns the shell-hard-coded home cluster, which that item does not touch)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (built-in-apps angle): the shell-hard-coded home Administration cluster and SystemHubPage sit OUTSIDE every merged-nav walk (builtin-apps-nav-render walks meta/app payloads; these links are React source, not metadata), and their history shows exactly this class dead-ending twice (objectui#3655). Grounding refined the register: the cluster is 9 entries (the register named 7 — Object Manager and Datasources are also in the source list), and the hub's five counts carry the #3679 null-vs-0 honesty semantics now pinned as their own clause", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.lifecycle-retention-sweep", + "title": "ADR-0057 lifecycle sweep: backdated telemetry rows past retention are reaped BOUNDED under a system context on the hourly sweep, onlyWhen scoping spares live rows, an archive-declaring object is NEVER hot-deleted unarchived (retained + reported archive-pending when no archive datasource exists), the transient class stays on the primary, and OS_LIFECYCLE_DISABLED=1 disarms the sweep", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["operator (local shell + direct sqlite access)", "seeded admin (API reads)"], + "fixtures": { + "app": "showcase", + "requires": [ + "an isolated boot on a file DB (-d file:/tmp//data.db) WITH direct sqlite access between boots — retention judges created_at, which no REST write lets a caller backdate, so the crash-shaped fixtures are hand-seeded into the sqlite file (the same fixture class platform-core.interrupted-migration-boot-report proved)", + "stock lifecycle declarations as the subjects: sys_automation_run (class telemetry, retention 30d, onlyWhen status ∈ completed|failed — the MIXED-table scoping), sys_audit_log (class audit, archive after 90d to the 'archive' datasource — the never-hot-delete subject), sys_inbox_message (class transient)", + "patience for the timer: the first sweep fires DEFAULT_LIFECYCLE_INITIAL_DELAY_MS = 60s after engine start, then hourly — every probe boot needs a ≥60s settle before reading results (eventual-consistency: do not read the absence of a reap before the sweep ran)" + ], + "knownGaps": [ + "the ARCHIVE-SUCCESS leg (rows copied to the archive datasource, then hot-deleted) needs a registered 'archive' datasource, which no stock boot has — that leg rides the unit pin (lifecycle-service.test.ts) and stays blocked(fixture) live; what IS live on stock is the SAFETY side: sys_audit_log rows older than 90d are RETAINED and the sweep reports the object skipped archive-pending", + "class-routing to a dedicated 'telemetry' datasource (ADR-0057 §3.6) engages only when a telemetry datasource is registered — absent on stock, so the routing leg is asserted at the unit/source level (engine.ts getDriver priority + SYSTEM_LEDGER_LIFECYCLE_CLASSES), not live", + "the ≤ REAP_BATCH_SIZE × REAP_MAX_BATCHES_PER_SWEEP bound (500×20 per object per sweep) is proven by the unit suite; staging >10k backdated rows live is not required — the live clause asserts the reap happened via the bounded per-id path (the aggregate log line), not the bound's edge" + ] + }, + "steps": [ + "boot once on the file DB so schema exists; stop the server", + "hand-seed via sqlite: (a) N sys_automation_run rows with created_at 40 days ago, status 'completed' (past the 30d window, terminal); (b) 2 rows created_at 40 days ago with status 'paused'/'running' (past the window but NOT terminal); (c) 1 sys_audit_log row created_at 100 days ago (past archive.after 90d); (d) 1 sys_inbox_message row (transient class). Record exact counts and ids", + "boot again (control boot, no env flag); wait ≥90s; grep the log for the aggregate sweep line '[lifecycle] sweep: … policy(ies) applied, ~… rows reaped …' (the ONE trace a sweep leaves — cleanup must not re-feed the tables it drains)", + "re-read via API/sqlite: the terminal 40d-old automation rows are GONE; the paused/running 40d-old rows SURVIVE (onlyWhen scoping); the 100d-old audit row SURVIVES and the sweep report lists sys_audit_log skipped archive-pending (no 'archive' datasource registered → retained, never dropped unarchived); the inbox row's fate follows its own declared policy only — the transient class got no cross-class treatment", + "idle proof: a second sweep interval later (or a fresh boot on the now-clean DB + 90s), the log carries NO sweep line — an idle sweep logs nothing (the line only prints when something was swept/errored/alerted)", + "kill switch: re-seed one terminal 40d-old automation row; boot with OS_LIFECYCLE_DISABLED=1; wait ≥90s; the row SURVIVES and no sweep line appears — and this absence is meaningful only because step 2 proved the sweep active on the same composition (absence-inference guarded)", + "run the unit pin for the legs live cannot stage: pnpm --filter @objectstack/objectql exec vitest run src/lifecycle/lifecycle-service.test.ts — the archive-declared skip ('skips hot deletion entirely while an archive is declared (retain → archive → delete)', report.skipped == [{object, reason: 'archive-pending'}]), the bounded-reap cases, and the rotation fallback" + ], + "acceptance": [ + { + "clause": "retention reaps on the real timer under a system context: rows past retention.maxAge are deleted by the hourly sweep (first sweep 60s after engine start) with the one aggregate log line as its trace — no REST caller, no per-plugin sweeper, exactly the platform-owned enforcer ADR-0057 §3.3 demands", + "oracle": "log", + "verify": "the sweep line from step 2's boot + the before/after row reads; the deletes happened with no API traffic from the run", + "evidence": "the log excerpt + both row inventories" + }, + { + "clause": "onlyWhen scopes the reap: 40d-old rows in NON-terminal statuses (paused/running) survive the same sweep that reaped their terminal neighbors — a suspended approval may legitimately stay paused for months, and the MIXED-table declaration exists for exactly this", + "oracle": "api", + "verify": "the surviving row ids from step 3 equal the seeded non-terminal set exactly", + "evidence": "the survivor read keyed by id" + }, + { + "clause": "an archive-declaring object is NEVER hot-deleted unarchived: with no 'archive' datasource registered, the 100d-old sys_audit_log row is retained and the object reported skipped archive-pending — a compliance ledger must not be dropped because its archive target is missing; the success path (copy then delete) rides the unit pin (blocked(fixture) live)", + "oracle": "test", + "verify": "live: the audit row survives step 3's sweep; pinned: the lifecycle-service.test.ts archive-pending case is green (report.skipped names the object and reason)", + "evidence": "the surviving audit row + the vitest output" + }, + { + "clause": "the reap is BOUNDED and per-id (#5194): at most REAP_BATCH_SIZE(500) × REAP_MAX_BATCHES_PER_SWEEP(20) rows per object per sweep, remainder draining across later sweeps — no unbounded multi:true DELETE holding the whole DB's write lock on the first sweep after a declaration lands on a big table", + "oracle": "test", + "verify": "the unit suite's bounded-reap cases; live corroboration: the sweep line's reaped count for the run's seeded population", + "evidence": "the vitest output + the sweep line" + }, + { + "clause": "OS_LIFECYCLE_DISABLED=1 disarms the whole schedule: the seeded expired row survives and no sweep line appears — scored ONLY against a composition where the armed control boot already proved the sweep live (the enabled getter short-circuits before any timer is armed)", + "oracle": "log", + "verify": "step 5's survivor + empty grep, bracketed by step 2's armed control", + "evidence": "the two boots' grep results side by side + the survivor read" + }, + { + "clause": "the class vocabulary this item's variants ride is the spec's own: LifecycleClassSchema = record|audit|telemetry|transient|event (5), with audit/telemetry/event forming the system-ledger carve-out set in the engine (SYSTEM_LEDGER_LIFECYCLE_CLASSES — transient deliberately absent because transient objects stay on the primary and never reach the gate)", + "oracle": "build", + "verify": "read the enum at packages/spec/src/data/object.zod.ts:820 and the engine set at packages/objectql/src/engine.ts:6582-6587; the enumSource pin below fails this item loudly when the spec grows a sixth class", + "evidence": "both source excerpts" + } + ], + "negative": [ + "a sweep deleting a paused/running row past the age window is a data-loss FAIL of onlyWhen — resumable workflow state reaped as history", + "a hot delete of an archive-declaring object's cold rows while the archive copy did not succeed is THE compliance fail the safety rule exists for — P1, extract on sight", + "reading 'no reap happened' before the 60s initial delay elapsed is the eventual-consistency trap, and scoring the kill switch on a boot never proven to sweep is absence-inference — both boots must bracket the same composition", + "a sweep that logs per-row (re-feeding the tables it drains) violates the one-aggregate-line design — capture as a finding, not a style nit", + "backdating rows through any API write instead of sqlite is impossible by design — a run that claims to have done so tested something else" + ], + "variants": [ + "class 'record' — default, no cross-class treatment", + "class 'audit' — archiver subject (sys_audit_log: archive after 90d, keep 7y); system-ledger carve-out member", + "class 'telemetry' — reaper subject (sys_automation_run 30d onlyWhen-terminal, sys_http_delivery); carve-out member", + "class 'transient' — stays on the primary, never routed (sys_inbox_message)", + "class 'event' — carve-out member (routing tier only on stock)" + ], + "enumSource": { + "file": "packages/spec/src/data/object.zod.ts", + "export": "LifecycleClassSchema", + "expect": 5 + }, + "traps": ["eventual-consistency", "clock-skew", "destructive-in-place", "absence-inference"], + "automated": { + "kind": "unit", + "ref": "packages/objectql/src/lifecycle/lifecycle-service.test.ts (archive-pending skip, bounded reap, rotation fallback, governance)" + }, + "source": [ + "packages/objectql/src/lifecycle/lifecycle-service.ts (the ADR-0057 enforcer: header contract :13-58 — reaper/rotator/archiver, the never-hot-delete-unarchived safety rule, #5194 bounds; SYSTEM_CTX :62; DEFAULT_LIFECYCLE_SWEEP_MS 3_600_000 :80; DEFAULT_LIFECYCLE_INITIAL_DELAY_MS 60_000 :83; REAP_BATCH_SIZE 500 / REAP_MAX_BATCHES_PER_SWEEP 20 :347-348; enabled getter incl. OS_LIFECYCLE_DISABLED :411-414; start()/timer :425-440; archive safety :984-995; the aggregate sweep line :718-730)", + "packages/objectql/src/plugin.ts:432-451 (registered as service 'lifecycle' by the engine plugin — every kernel with data has enforcement; a declared retention driving no sweeper is ADR-0049 dead surface), :755-756 (armed at start), :774-778 (#4747 disarm on destroy)", + "packages/objectql/src/engine.ts:6582-6587 (SYSTEM_LEDGER_LIFECYCLE_CLASSES = audit|telemetry|event; transient deliberately absent) + the getDriver §3.6 class-routing priority note", + "packages/spec/src/data/object.zod.ts:820 (LifecycleClassSchema, 5 members)", + "packages/services/service-automation/src/sys-automation-run.object.ts:51-57 (telemetry 30d onlyWhen terminal — the MIXED-table rationale in its own comment), packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts:24-31 (archive 90d/7y + the never-hot-delete note), packages/services/service-messaging/src/objects/inbox-message.object.ts:28 (transient), http-delivery.object.ts:39 (telemetry)", + "ADR-0057 (§3.3 single enforcer, system context, isolation; §3.6 class separation), #5194 (bounded reap), #2834 (the automation-run declaration)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (spec-enums angle): the ADR-0057 lifecycle vocabulary had items nowhere though stock objects declare all the interesting shapes. Grounding sharpened the register on three points: (1) sys_audit_log stock-declares archive 90d, so the never-hot-delete SAFETY side is live-stageable (the success side stays unit-pinned); (2) the sweep has NO on-demand door — first fire is 60s after engine start, so every probe brackets that delay and the kill-switch clause requires an armed control boot; (3) the enum pin is LifecycleClassSchema expect 5, verified by member count at object.zod.ts:820", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.seed-mode-matrix", + "title": "Seed mode matrix (insert/update/upsert/replace/ignore): each mode does exactly its verb through the ONE SeedLoaderService, no-op replays skip instead of churning, ignore reports skips — and `replace` is DECLARED delete-all-then-insert but IMPLEMENTED as bare insert (no caller clears the table): the posture is recorded, never ticked as scoped deletion", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a runtime-created WRITABLE package (the metadata-authoring-roundtrip fixture) — `seed` is allowRuntimeCreate, so per-mode scratch seeds are authored as drafts (PUT /api/v1/meta/seed/ with an explicit `mode`) and applied by publish (applySeedBodies materializes published seed bodies through the SAME SeedLoaderService the boot path uses — packages/runtime/src/seed-loader.ts is a re-export shim of it)", + "a scratch target object in the same writable package (do NOT aim mode probes, least of all replace/insert replays, at seeded showcase objects — destructive-in-place)" + ], + "knownGaps": [ + "the publish response's seedApplied summary surfaces inserted/updated/errors but not per-row skip detail — the `ignore says so` clause therefore reads the skip from effect (counts + untouched values) plus the loader's summary where surfaced, not from a UI report", + "`insert` mode's schema comment says 'fail on duplicate', but the loader does NO matching in insert mode (no existing-records preload) — the failure only materializes where the object itself carries a unique constraint; on an unconstrained scratch object an insert replay simply duplicates rows. The matrix records both shapes honestly" + ] + }, + "steps": [ + "author the scratch object (2 fields + a natural-key `name`) into the writable package and publish; note `externalId` defaults to 'name' and `defaultMode` to 'upsert' (SeedLoaderRequestSchema)", + "UPSERT: publish a seed draft (mode upsert) with rows A,B → both insert; edit the draft changing B's value and adding C; re-publish → A skipped (no-op replay — only seed-declared fields compared, so runtime edits to OTHER columns never block the skip), B updated in place (same id), C inserted; verify by id stability + values", + "UPDATE: seed draft (mode update) touching A (changed value) and a nonexistent D → A updates, D is SKIPPED (update never creates); row count unchanged", + "IGNORE: seed draft (mode ignore) with A (changed value) and new E → A untouched (still the update-mode value; existing rows are never rewritten), E inserted; the skip is visible as A's unchanged value + count", + "INSERT: seed draft (mode insert) re-declaring A → a SECOND A-named row appears (no matching in insert mode); record the duplication; if the object declares a unique key on the natural key instead, record the refusal — either shape is the mode's real contract", + "REPLACE (expected-posture probe): add one row F by hand through /api/v1/data (a row the seed does NOT declare); publish a seed draft (mode replace) declaring only G → capture what happens to F. At head: F SURVIVES and G is inserted — the loader's replace case is bare insert with the comment 'caller should have cleared the table', and NO caller in the tree clears anything. Record the declared-vs-implemented gap; do NOT tick 'replace scoped to its own object' (there is no deletion to scope) and do NOT file F's survival as data loss (it is the opposite)", + "idempotence tie-in: re-publish the upsert draft unchanged → zero updates (all no-op skips), updated_at values untouched (the churn-avoidance isNoOpReplay exists for exactly this)", + "teardown: the writable package's own delete, or discard the isolated DB" + ], + "acceptance": [ + { + "clause": "upsert (the default) matches on externalId and does exactly create-or-update: unmatched rows insert, matched-and-different rows update IN PLACE (id stable), matched-and-identical rows SKIP as no-op replays — comparison covering only seed-declared fields so runtime edits to other columns never block the skip", + "oracle": "api", + "verify": "the id/value table across the two upsert publishes from step 1; ids stable, values as authored, A untouched on the replay", + "evidence": "the before/after row reads keyed by id" + }, + { + "clause": "update only updates: an unmatched record is skipped, never created — row count unchanged across an update-mode publish declaring a nonexistent key", + "oracle": "api", + "verify": "count + the absent D after step 2", + "evidence": "the count pair + the D lookup (empty)" + }, + { + "clause": "ignore only fills gaps: existing rows are never rewritten (A keeps its pre-publish value verbatim) while new rows insert — and the skip is provable from effect (unchanged value + counts), not assumed from silence", + "oracle": "api", + "verify": "A's value byte-identical across step 3; E present", + "evidence": "A's before/after read + E's read" + }, + { + "clause": "insert never matches: a replay adds rows again on an unconstrained object (the duplication is the honest observation), and only an object-level unique constraint turns it into the schema comment's 'fail on duplicate' — record which shape this run's fixture produced", + "oracle": "api", + "verify": "the duplicate A-named rows (or the constraint refusal) after step 4", + "evidence": "the post-insert row listing (or the refusal body)" + }, + { + "clause": "EXPECTED POSTURE (declared ≠ implemented): `replace` is documented 'Delete ALL records in object then insert (Dangerous)' but the implementation is bare insert — writeRecord's replace arm inserts with the comment 'caller should have cleared the table' and NO caller anywhere clears it, so pre-existing rows SURVIVE a replace-mode publish. The run records this gap (a checklist-accuracy/product finding routed through the sweep's anchor, not re-filed per run); it must NOT tick a 'replace deletes only its own object's rows' claim — no deletion exists to scope. If a future head implements the clear, this clause is stale: revise, and then ALSO prove the deletion is scoped to the seed's own object before trusting it", + "oracle": "api", + "verify": "F (undeclared, hand-inserted) still present after the replace publish; G inserted; grep evidence: the only 'replace' write arm is the insert at seed-loader.ts:2062-2065 and decideWriteAction:2106 routes replace to insert", + "evidence": "F's surviving read + G's read + the source excerpt" + }, + { + "clause": "the mode vocabulary is the spec's own five and the resolution seam is one line: dataset.mode || config.defaultMode ('upsert'), through the single SeedLoaderService every path shares (boot, publish, dispatcher) — the enumSource pin fails this item when a sixth mode lands", + "oracle": "build", + "verify": "SeedMode members at seed.zod.ts:12-18; the resolution at metadata-protocol/src/seed-loader.ts:583; the runtime re-export shim proving one implementation", + "evidence": "the three source excerpts" + } + ], + "negative": [ + "an upsert replay that bumps updated_at on identical rows is the churn isNoOpReplay exists to prevent (phantom edits in history, state-machine re-validation) — FAIL the skip clause", + "update creating a row, or ignore rewriting one, is a mode doing another mode's verb — FAIL", + "ticking the replace clause as 'deletion correctly scoped' is a FALSE PASS twice over: nothing is deleted at head, and the clause exists to record exactly that", + "aiming replace/insert replays at seeded showcase objects contaminates platform-core.seed-integrity's baselines — destructive-in-place; scratch objects only" + ], + "variants": [ + "mode insert — no matching, always writes (duplicates on replay unless the object's own unique constraint refuses)", + "mode update — matched rows only; unmatched skipped, never created", + "mode upsert — create-or-update on externalId with no-op-replay skip (the default)", + "mode replace — DECLARED clear-then-insert, IMPLEMENTED bare insert (the expected-posture clause)", + "mode ignore — insert-if-absent; existing rows never rewritten" + ], + "enumSource": { + "file": "packages/spec/src/data/seed.zod.ts", + "export": "SeedMode", + "expect": 5 + }, + "traps": ["destructive-in-place", "seed-data-thin", "silent-coercion"], + "source": [ + "packages/spec/src/data/seed.zod.ts:12-18 (SeedMode — the five modes with their declared semantics, incl. replace's 'Delete ALL records … Dangerous' claim)", + "packages/metadata-protocol/src/seed-loader.ts:583 (mode = dataset.mode || config.defaultMode), :634-640 (existing-records preload for upsert/update/ignore ONLY — insert/replace never match), :2020-2075 (writeRecord per-mode switch; the replace arm's bare insert + 'caller should have cleared the table' comment at :2062-2065), :2084-2112 (decideWriteAction — the batched mirror, replace → insert), :2117+ (isNoOpReplay — seed-declared fields only, conservative on doubt)", + "packages/spec/src/data/seed-loader.zod.ts:244 (defaultMode default 'upsert')", + "packages/metadata-protocol/src/protocol.ts:15603+ (applySeedBodies — published seed bodies materialize through the same loader; failures returned, never thrown, surfaced as seedApplied)", + "packages/runtime/src/seed-loader.ts (the MOVED shim — one SeedLoaderService for every path)", + "packages/spec/src/kernel/metadata-plugin.zod.ts (seed: allowRuntimeCreate true — the scratch-draft fixture path)", + "platform-core.seed-integrity (owns the stock boot-seed baselines this item must not contaminate)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (spec-enums angle), with the register's central claim CORRECTED against source: the register said 'replace = clear-then-insert (decideWriteAction)', but at head NOTHING clears — writeRecord's replace arm is a bare insert whose own comment defers to a caller that does not exist (grepped: no clearTable/deleteMany/delete caller anywhere on the seed path), so pre-existing rows survive a replace publish. The clause is therefore an expected-posture record of the declared≠implemented gap, not a scoped-deletion assertion. Also recorded honestly: insert-mode 'fail on duplicate' rides the object's own constraints (the loader does no matching in insert mode)", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.theme-mode-persistence", + "title": "Theme mode: dark/light apply the root class immediately and survive reload via localStorage (vite-ui-theme); system/auto resolves the OS preference at evaluation time (#2942 — never a dead 'auto' class); no half-themed surface after a toggle", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": ["any stock console boot; a driver that can emulate the OS color-scheme preference (CDP prefers-color-scheme override) for the system-mode cells"] + }, + "steps": [ + "open the console; locate the ModeToggle in the AppHeader; read the initial documentElement class and localStorage['vite-ui-theme']", + "pick Dark: documentElement gains class 'dark' IMMEDIATELY (no reload); the toggle shows the checkmark on Dark; screenshot a data-dense surface (list + header + sidebar) and confirm no half-themed region (a light-styled panel inside the dark shell)", + "reload: the dark class is present on load (localStorage read at provider init); localStorage carries 'dark'", + "pick Light: class flips to 'light' immediately; reload-persists the same way", + "pick System with the browser's emulated preference set to dark: the resolved class is 'dark' and localStorage stores 'system'; flip the emulated preference and RELOAD: the class follows the new preference (resolution happens at provider evaluation — the provider attaches NO matchMedia listener, so a live OS flip without reload updating the class is NOT asserted; assert resolution at load, and record live-tracking as not-implemented if probed)", + "regression probe (#2942): with a stored value of 'auto' (write it into localStorage by hand — the spec's ThemeModeSchema spelling), reload: the class resolves to the OS preference, never a literal 'auto' class locking light styling" + ], + "acceptance": [ + { + "clause": "explicit dark/light apply the root class synchronously on selection and persist across reload via localStorage vite-ui-theme", + "oracle": "dom", + "verify": "documentElement.classList before/after each pick and after reload; localStorage value at each step", + "evidence": "the class/storage trace" + }, + { + "clause": "system (and the spec's 'auto' spelling) resolve the OS preference at evaluation: emulated-dark yields the dark class, emulated-light the light class, on selection and on reload — and a stored 'auto' never lands as a dead literal class (#2942)", + "oracle": "dom", + "verify": "the four (stored value × emulated preference) cells read off documentElement after reload; the hand-written 'auto' cell included", + "evidence": "the cell matrix" + }, + { + "clause": "no half-themed surface: after each toggle a dense surface renders coherently in the selected theme — no region stuck in the other palette", + "oracle": "screenshot", + "verify": "the per-theme screenshots of the same surface, eyeballed for stuck regions; any candidate re-checked after settle (hydration-race)", + "evidence": "the screenshot pairs" + } + ], + "negative": [ + "a theme that applies only after reload (no immediate class flip) fails the immediacy half; one that flips immediately but resets on reload fails persistence — the two halves are separate verdicts", + "asserting LIVE OS-preference tracking (class following a preference flip without reload) would tick a listener that does not exist — the provider resolves once per evaluation; record the limitation, do not fail the product for the item's own over-claim", + "the theme resetting when a DIFFERENT browser profile/tab is used is not a finding — localStorage is per-origin-per-profile by design (shared-browser-tab: own the tab)" + ], + "traps": ["shared-browser-tab", "hydration-race"], + "source": [ + "objectui packages/app-shell/src/chrome/ThemeProvider.tsx:27-33 (storageKey 'vite-ui-theme', initial read), :35-54 (the class effect — 'auto'/'system' resolve matchMedia ONCE per evaluation; the #2942 note: branching on 'system' alone sent 'auto' into classList.add('auto'), a class no Tailwind variant matches), :57-62 (setTheme persists then applies)", + "objectui packages/app-shell/src/layout/ModeToggle.tsx:6-38 (the three-way menu with checkmarks) + AppHeader.tsx:877 (mounted in the header)", + "objectui#2942 (the auto-class regression this item's last step re-probes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (console-UI angle, optional-P2 register row). Grounding tightened one claim: the provider attaches NO matchMedia change listener, so 'system tracks the OS preference' holds at evaluation time only — the item asserts resolution on selection/reload and explicitly forbids failing the product for live-flip tracking it never implemented", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "platform-core.console-installability-indicators", + "title": "Console installability + connectivity indicators: the PWA manifest is a runtime-branded blob injected at boot, the offline pill appears on connectivity loss and clears on restore, the connection status chip shows transient/error states honestly — and NO service worker is registered on a stock console (caching is opt-in via useETagCache)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": ["a stock console boot; a driver that can emulate network loss (CDP offline emulation) for the offline-pill cells"] + }, + "steps": [ + "after console load, read document.head: exactly one link[rel=manifest] whose href is a blob: URL (the static /manifest.json link is REMOVED and replaced at boot); fetch the blob from page context and parse it — name/short_name/description/theme_color carry the runtime branding (the values GET /api/v1/runtime/config served), icons point at the branded favicon", + "confirm the branded favicon: the #favicon link's href/type follow the runtime config's faviconUrl", + "emulate offline: the header renders the Offline pill (yellow, pulsing dot, 'Offline' label — gated on useOffline().isOnline); screenshot; restore connectivity: the pill clears", + "connection status chip: on an app surface, observe the transient 'Connected' flash auto-hiding after ~2s; then stop the backend briefly (or emulate request failure) and confirm the chip shows the reconnecting/error state prominently instead of hiding", + "service-worker honesty: in page context run navigator.serviceWorker.getRegistrations() → EMPTY on a stock console (no SW is registered anywhere in the boot path; ETag caching is an opt-in hook, useETagCache, which no stock surface arms) — assert the absence so no run or doc claims offline-first caching the product does not ship" + ], + "acceptance": [ + { + "clause": "the PWA manifest is generated from runtime branding and injected as a blob link replacing the static one — install prompts would carry the deployment's own name/colors, not a hardcoded default", + "oracle": "dom", + "verify": "the single blob manifest link + the parsed blob's fields diffed against the same boot's runtime-config branding", + "evidence": "the parsed manifest beside the runtime-config body" + }, + { + "clause": "the offline pill is live both ways: appears under emulated offline, clears on restore — connectivity state is surfaced, not silently swallowed", + "oracle": "screenshot", + "verify": "the offline/online screenshot pair bracketing the emulation toggle", + "evidence": "both screenshots" + }, + { + "clause": "the connection chip is honest about states: connected shows briefly then hides (no permanent green noise); reconnecting/error show prominently and persist while true", + "oracle": "screenshot", + "verify": "the transient flash observed on load; the error/reconnecting state provoked and captured; if the error state cannot be provoked cheaply, record that leg skipped rather than assumed", + "evidence": "the state screenshots (or the explicit skip note)" + }, + { + "clause": "NO service worker on stock: getRegistrations() is empty — the honesty assertion that keeps 'installable PWA' claims scoped to what ships (a manifest and indicators, not offline caching)", + "oracle": "dom", + "verify": "the in-page getRegistrations() result on a settled stock console", + "evidence": "the empty registrations read" + } + ], + "negative": [ + "a static unbranded /manifest.json surviving as the effective manifest (white-label deployments would install under the framework's name) is a FAIL of the injection", + "an offline pill that never clears after restore, or never appears when offline, fails its respective direction — test both", + "a service worker found registered on stock is a CHANGE to the shipped posture, not automatically a bug — capture and file for a maintainer decision rather than ticking or failing", + "reading the offline pill's absence immediately after the emulation toggle is a race — settle first (the hook listens to browser online/offline events)" + ], + "traps": ["absence-inference", "hydration-race", "stale-console-bundle"], + "source": [ + "objectui apps/console/src/main.tsx:100-132 (branded favicon swap; generatePWAManifest from runtime branding → blob URL; the static manifest link removed and replaced)", + "objectui packages/app-shell/src/layout/AppHeader.tsx:132 (useOffline), :587-596 (the Offline pill + the ConnectionStatus mount)", + "objectui packages/app-shell/src/layout/ConnectionStatus.tsx:20-46 (the five states; connected auto-hides after 2s; error/reconnecting prominent)", + "objectui packages/react/src/hooks/useETagCache.ts (the OPT-IN caching hook — serviceWorkerUrl config exists but nothing stock arms it; the basis of the no-SW assertion)", + "platform-core.runtime-config-boot-read (the branding source these indicators consume)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "new — authored in the 2026-08-30 sweep (console-UI angle, optional-P2 register row; kept rather than dropped because every clause carries a real oracle: the blob manifest is parseable, the offline pill is drivable via CDP emulation, and the no-service-worker posture is a one-line in-page read). The service-worker clause is deliberately an ABSENCE assertion — it keeps installability claims honest about what ships", + "ref": "#sweep-2026-08-30" + } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index a3a143e058..ae5394b823 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -1625,10 +1625,10 @@ }, { "id": "records-forms.validation-rule-type-matrix", - "title": "All six validation-rule types enforce on the write path with their exact per-type error codes", + "title": "All six validation-rule types enforce on the write path with their exact per-type error codes — and advisory severities flag without blocking", "since": "v15", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -1637,7 +1637,11 @@ "fixtures": { "app": "showcase", "requires": [ - "the seeded per-type rules: showcase_account account_lifecycle (state_machine), tax_id_format + billing_email_format (format), support_config_shape (json_schema), churn_reason_consistency (conditional); showcase_project end_after_start (cross_field), spent_within_budget (script), project_status_flow (state_machine)" + "the seeded per-type rules: showcase_account account_lifecycle (state_machine), tax_id_format + billing_email_format (format), support_config_shape (json_schema), churn_reason_consistency (conditional); showcase_project end_after_start (cross_field), spent_within_budget (script), project_status_flow (state_machine)", + "the seeded ADVISORY rule: showcase_project project_health_progression — state_machine on `health` with severity:'warning' (green↔yellow↔red one step at a time; a jump is flagged, not blocked — examples/app-showcase/src/data/objects/project.object.ts:149-167)" + ], + "knownGaps": [ + "no showcase rule declares severity:'info' — it shares the identical non-error branch with 'warning' (rule-validator.ts:1861-1868 tests `severity === 'error'` and logs everything else), so the warning leg is the driven proof; author a scratch info rule only if a distinct observation is wanted, and record which severities were actually driven" ] }, "variants": [ @@ -1646,14 +1650,16 @@ "json_schema — off-schema support_config → json_schema_violation; non-JSON string → invalid_json", "cross_field — project end_date < start_date → rule_violation naming end_date", "script — spent > budget → rule_violation", - "conditional — status 'churned' without churn_reason → the wrapped rule fires; with churn_reason present it does not" + "conditional — status 'churned' without churn_reason → the wrapped rule fires; with churn_reason present it does not", + "severity — 'error' (the default, validation.zod.ts:129) blocks; 'warning'/'info' rules that MATCH do not block: the write lands and the message is logged server-side (rule-validator.ts:56 'only error blocks', :1861-1868)" ], "steps": [ "boot showcase isolated; sign in as admin", "for each variant: POST/PATCH the violating payload over /api/v1/data/; capture status + error body", "for each variant: send the happy-path twin (same shape, satisfying values) and capture success", "after every rejection, re-read the row set to confirm nothing persisted", - "for state_machine additionally: create with a legal initial state, walk one legal transition, then attempt the illegal one" + "for state_machine additionally: create with a legal initial state, walk one legal transition, then attempt the illegal one", + "severity probe: PATCH a showcase_project's `health` from green straight to red (skipping yellow — a jump project_health_progression matches); capture the response, re-read the row, and read the server log for the warning line" ], "acceptance": [ { @@ -1679,18 +1685,26 @@ "oracle": "api", "verify": "the six success responses + persisted rows", "evidence": "the twins" + }, + { + "clause": "a MATCHING advisory rule does not block, and its message still surfaces where the contract puts it — the SERVER LOG, not the response: the green→red health jump answers 2xx, the row persists with the jumped value, and the log carries \"Validation rule 'project_health_progression' (warning): Health changed by more than one step…\" — advisory rules stay advisory (only severity 'error' feeds the ValidationError throw)", + "oracle": "log", + "verify": "the 2xx response + the persisted re-read + the logger.warn line (packages/objectql/src/validation/rule-validator.ts:1861-1868 — non-error severities route to opts.logger.warn, never into `errors`). ⛔ Do not require the message in the RESPONSE body — best-effort logging is the documented surface; a run wanting a UI-visible advisory is asking for a feature, not verifying this one", + "evidence": "response + re-read + the captured log line" } ], "negative": [ - "an unevaluable CEL expression must fail CLOSED (rule_violation), never fail-open silently accepting the write — the rule-validator's documented posture" + "an unevaluable CEL expression must fail CLOSED (rule_violation), never fail-open silently accepting the write — the rule-validator's documented posture", + "the fail-closed posture is SEVERITY-SCOPED: an unevaluable 'warning'/'info' rule stays non-blocking (logged, never thrown — rule-validator.ts:86-88 'advisory rules stay advisory'); a warning rule that BLOCKS a write, evaluable or not, is a FAIL in the other direction", + "a matching warning rule that leaves NO log line is a FAIL of the surfacing half — flagged-not-blocked means flagged" ], "traps": [ "wrong-persona" ], "source": [ - "packages/spec/src/data/validation.zod.ts (ValidationRuleSchema, 6 discriminated variants)", - "packages/objectql/src/validation/rule-validator.ts (evaluateRule switch + per-type codes)", - "examples/app-showcase/src/data/objects/{account,project,task}.object.ts (the seeded rules)", + "packages/spec/src/data/validation.zod.ts (ValidationRuleSchema, 6 discriminated variants; :129 severity enum ['error','warning','info'] default 'error')", + "packages/objectql/src/validation/rule-validator.ts (evaluateRule switch + per-type codes; :56 'only error blocks', :86-88 unevaluable-advisory stays non-blocking, :1861-1868 the severity fork)", + "examples/app-showcase/src/data/objects/{account,project,task}.object.ts (the seeded rules; project.object.ts:149-167 the advisory project_health_progression)", "#1475 (declared ≠ enforced history: 9 declared → 6 declared+enforced)" ], "history": [ @@ -1699,6 +1713,12 @@ "date": "2026-08-07", "change": "initial — gap found by the capability sweep: 6 rule types all seeded, none individually asserted anywhere", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-30", + "change": "added the severity axis the matrix silently assumed away: every clause drove severity:'error' (the default) while the spec declares 'warning'/'info' and the evaluator routes them to a non-blocking logger.warn — a matching advisory rule was never proven NOT to block, and its message never proven to surface. New variant line, step, acceptance clause (log oracle — the message's contract surface is the server log, not the response) and two negatives, including that fail-closed (#4649) is severity-scoped. Register corrected against source: the sweep's claim that showcase rules are all default-error is FALSE — project.object.ts:149-167 ships the advisory project_health_progression precisely for this, so the warning leg is stock-drivable; only 'info' (same code branch) lacks a specimen", + "ref": "#sweep-2026-08-30" } ] }, @@ -2296,10 +2316,10 @@ }, { "id": "records-forms.record-discussion-mentions", - "title": "Record discussion: an @mention comment reconciles optimistically, persists to sys_comment, interleaves with activity, and pings the mentioned user's bell", + "title": "Record discussion: an @mention comment reconciles optimistically, persists to sys_comment, interleaves with activity, pings the mentioned user's bell — and the feed filter shows exactly each mode's subset without dropping author-extended activity", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -2332,7 +2352,9 @@ "confirm a NON-mentioned user's bell does NOT gain the notification (recipient scoping)", "CREATE-SIDE FEEDS_DISABLED probe — as admin, POST one throwaway qa_nofeeds record ({ name: 'qa-nofeeds-probe' }) for a real parent_id (qa_nofeeds is a scratch OBJECT from the qa-feeds-disabled recipe with no standing rows), then POST /api/v1/data/sys_comment with { thread_id: 'qa_nofeeds:', body: 'should be refused by the feeds gate' } and capture the refusal. Admin can read AND edit that record (public_read_write), which is what isolates the refusal from the sibling RECORD_NOT_ACCESSIBLE code", "RE-THREAD (UPDATE-SIDE) FEEDS_DISABLED probe — take the sys_comment row posted earlier against the feeds-ENABLED record and PATCH /api/v1/data/sys_comment/ with { thread_id: 'qa_nofeeds:' } as admin, capturing the refusal. This is the UPDATE half of the #10170 gate (enforceFeedsCapability registered on beforeUpdate as well as beforeInsert, packages/plugins/plugin-audit/src/audit-writers.ts:1456-1457): re-read the comment afterward and confirm its thread_id is UNCHANGED", - "control for both probes — repeat the same two calls against a feeds-ENABLED parent (the seeded showcase record used above) and confirm they SUCCEED. A 403 that also fires on the enabled parent is measuring access, not the capability gate, and neither clause may be scored from it" + "control for both probes — repeat the same two calls against a feeds-ENABLED parent (the seeded showcase record used above) and confirm they SUCCEED. A 403 that also fires on the enabled parent is measuring access, not the capability gate, and neither clause may be scored from it", + "FEED FILTER — on a record whose feed now holds at least one comment (posted above) and one field-change activity (the attributable edit above), cycle the filter control through all four FeedFilterMode values, screenshotting each state before reading the rendered rows", + "OPEN-VOCABULARY probe — plant an author-EXTENDED activity type: sys_activity narrows apiMethods to ['get','list'] (sys-activity.object.ts:290-294), so a direct POST is refused BY DESIGN — the sanctioned channel is a scratch object declaring activityMilestones[].type with a custom value (ADR-0052 §5b.2, forwarded verbatim) whose milestone the run then triggers; if that authoring is unavailable on the boot under test, score the clause from the objectui pin (recordActivityFeed.test.ts) and record the substitution" ], "acceptance": [ { @@ -2376,6 +2398,18 @@ "oracle": "api", "verify": "the PATCH re-threading an existing sys_comment onto the qa_nofeeds record answers 403 FEEDS_DISABLED, and a re-read of that sys_comment row shows thread_id still naming the ORIGINAL feeds-enabled parent — same authoritative-rejection rule as the create clause above. Grounded engine-side by the #10170 pin describe '[#10170] enable.feeds is asked on the UPDATE verb too' (packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts:341), which covers the by-id and predicate shapes; this clause is the REST-surface half that pin does not reach. Note the deliberate boundary: an update that carries NO thread_id is not a re-thread and stays allowed, so an ordinary body edit on a grandfathered row must still succeed", "evidence": "the 403 response body, the unchanged-thread_id re-read, and the body-edit control" + }, + { + "clause": "PER-VARIANT: each of the four FeedFilterMode values shows exactly its subset — 'all' the full merged feed, 'comments_only' rows of feed type comment, 'changes_only' rows of feed type field_change, 'tasks_only' rows of feed type task (an EMPTY subset is a correct rendering when the record has no rows of that type — record it, don't fail it) — and switching back to 'all' restores every row", + "oracle": "dom", + "verify": "after each screenshot settles, the rendered rows against the mode's filter arm (objectui RecordActivityTimeline.tsx:164-175 filterItems; the four options offered are getFilterOptions :133-140). The mode TYPE is imported from @objectstack/spec/data (:31), so the control cannot offer a mode the spec does not declare", + "evidence": "four screenshots + the per-mode row census against the API-read feed" + }, + { + "clause": "an author-extended sys_activity.type is NEVER dropped: a value outside the built-in map renders in the 'all' feed through the generic 'system' presentation (UNMAPPED_ACTIVITY_FEED_TYPE) with a named diagnostic — the #11507 open-vocabulary ruling (2026-08-24): the column is a floor, not a ceiling, and 'stored, queryable, invisible' is the failure mode the fallback exists to kill. Under the three narrowing filter modes the system-bucketed row is correctly absent (it is not a comment/field_change/task) — absence THERE is not the drop this clause hunts", + "oracle": "dom", + "verify": "the planted custom-type row appears in the 'all' feed with the generic presentation (objectui recordActivityFeed.ts: ACTIVITY_TYPE_TO_FEED_TYPE is a SUPERSET pin over built-ins, unmapped values route to UNMAPPED_ACTIVITY_FEED_TYPE 'system' — objectui#5840/#5969 history); where the milestone channel is unavailable, the pin recordActivityFeed.test.ts is the fallback oracle — record which the verdict rests on", + "evidence": "the 'all' feed screenshot showing the custom-type row (or the pin output + the substitution note)" } ], "negative": [ @@ -2383,8 +2417,21 @@ "a mention notification delivered to a NON-mentioned user is a FAIL — the recipientId gate is the boundary", "feeds:false must HIDE the panel, SKIP the sys_comment fetch, AND the server must reject new comments with 403 FEEDS_DISABLED — a silent no-op that accepts a comment nowhere-readable is a FAIL. The two server halves are now DRIVEN by acceptance clauses 6 and 7 against the qa-feeds-disabled recipe's qa_nofeeds parent; until #12118 this bullet was the only place the code appeared in this area, which made it read as covered while nothing provoked it", "a re-thread that is refused but LEAVES the row moved (or is accepted outright) is a FAIL — #10170 registered the gate on beforeUpdate precisely because a caller barred from creating a comment on a feeds-disabled object could otherwise re-point an existing one into it", - "scoring either FEEDS_DISABLED clause from the ENABLED control alone is a FAIL — the control exists to prove the refusal is attributable to the capability gate rather than to record access (RECORD_NOT_ACCESSIBLE, comment-access-hooks.ts:132), so a run that reports the 403 without the 2xx control has not measured the gate" + "scoring either FEEDS_DISABLED clause from the ENABLED control alone is a FAIL — the control exists to prove the refusal is attributable to the capability gate rather than to record access (RECORD_NOT_ACCESSIBLE, comment-access-hooks.ts:132), so a run that reports the 403 without the 2xx control has not measured the gate", + "a filter mode showing a row of another mode's type — or dropping one of its own — is a FAIL; and an unknown activity type absent from the 'all' feed is a FAIL (the objectui#5840 shape: stored, queryable, invisible), while its absence from the three NARROWING modes is correct behavior a run must not file as the drop" ], + "variants": [ + "all — the full merged comment + activity feed", + "comments_only — feed type comment", + "changes_only — feed type field_change", + "tasks_only — feed type task", + "(open-vocabulary floor) an author-extended sys_activity.type outside the built-in map → generic 'system' presentation in 'all', never dropped (#11507)" + ], + "enumSource": { + "file": "packages/spec/src/data/feed.zod.ts", + "export": "FeedFilterMode", + "expect": 4 + }, "traps": [ "hydration-race", "automation-input", @@ -2395,7 +2442,9 @@ "objectui: packages/app-shell/src/views/RecordDetailView.tsx (mergeFeedRows union-by-id, sys_comment + sys_activity fetch/merge, mentionSuggestions), packages/plugin-detail/src/renderers/recordActivityFeed.ts (activity→feed map)", "objectui: packages/collaboration/src/useMentionNotifications.ts (recipient-scoped bell, unreadCount)", "framework: packages/spec/src/data/object.zod.ts:290 (enable.feeds default true → FEEDS_DISABLED, 'a new comment and an update that re-threads an existing one alike'); PENDING-GAPS §B record-discussion-mentions", - "framework: packages/plugins/plugin-audit/src/audit-writers.ts:1440-1457 (enforceFeedsCapability, registered on beforeInsert AND beforeUpdate per #10170), packages/plugins/plugin-audit/src/comment-access-hooks.ts:132 (RECORD_NOT_ACCESSIBLE — the sibling refusal the probes isolate against), packages/plugins/plugin-audit/src/objects/sys-comment.object.ts:9 (thread_id is conventionally `{object}:{record_id}`)" + "framework: packages/plugins/plugin-audit/src/audit-writers.ts:1440-1457 (enforceFeedsCapability, registered on beforeInsert AND beforeUpdate per #10170), packages/plugins/plugin-audit/src/comment-access-hooks.ts:132 (RECORD_NOT_ACCESSIBLE — the sibling refusal the probes isolate against), packages/plugins/plugin-audit/src/objects/sys-comment.object.ts:9 (thread_id is conventionally `{object}:{record_id}`)", + "framework: packages/spec/src/data/feed.zod.ts:51-56 (FeedFilterMode, 4 members — pinned above), :57+ (SYS_ACTIVITY_BUILTIN_TYPES + the #11507 open-vocabulary ruling: built-ins are the floor, never the ceiling); packages/plugins/plugin-audit/src/objects/sys-activity.object.ts:290-294 (apiMethods ['get','list'] — why the probe cannot direct-POST)", + "objectui: packages/plugin-detail/src/RecordActivityTimeline.tsx:31 (FeedFilterMode imported from the spec), :133-140 (the four filter options), :164-175 (filterItems); packages/plugin-detail/src/renderers/recordActivityFeed.ts (ACTIVITY_TYPE_TO_FEED_TYPE superset map + UNMAPPED_ACTIVITY_FEED_TYPE fallback + its diagnostic; drift held by the objectui#5969 two-directional pin, recordActivityFeed.test.ts)" ], "automated": { "kind": "api", @@ -2413,6 +2462,12 @@ "date": "2026-08-25", "change": "drove the FEEDS_DISABLED case this item only NAMED. The code appeared in one `negative` bullet with no step and no acceptance clause, and nothing in the area — or in stock showcase, where feeds is opt-out and therefore on everywhere — provided a feeds-disabled parent to provoke it, so an auditor grepping records-forms.json read coverage that was never driven. Added the area's first area-level fixtures block (recipe qa-feeds-disabled, landing a scratch qa_nofeeds object with an EXPLICIT enable.feeds:false, modelled on qa-scratch-authz #7670), opted this item into it, and added three steps + acceptance clauses 6 and 7: the create-side probe (POST sys_comment onto qa_nofeeds → 403 FEEDS_DISABLED, zero rows) and the re-thread probe (PATCH an existing comment's thread_id onto it → 403, thread_id unchanged), each with a feeds-ENABLED control that makes the refusal attributable to the capability gate rather than to RECORD_NOT_ACCESSIBLE. Added `automated` naming the pre-existing engine/API pins (#10170 capability-gate-update-verb.test.ts, the cmt_nofeeds dogfood probe) and stating precisely what they do NOT reach, so the next auditor does not have to re-derive it. Structural twin of #12116, which repaired the same shape for FILES_DISABLED one area over and reported this side rather than widening.", "ref": "#12118" + }, + { + "revision": 3, + "date": "2026-08-30", + "change": "added the feed-filter axis: the four FeedFilterMode values (spec feed.zod.ts:51) and the #11507 open-vocabulary floor (an author-extended sys_activity.type renders via the generic fallback, never dropped) had no coverage — the item drove the feed's content but never its filter control. Two clauses, a variants list, and the FeedFilterMode enumSource pin (expect 4) so a fifth mode trips VARIANTS STALE. Register corrected against source on two counts: the mode type is NOT hand-local in objectui (RecordActivityTimeline.tsx:31 imports it from @objectstack/spec/data, and the icon/color maps are Total over FeedItemType with the objectui#5969 two-directional pin — no drift-risk caveat warranted), and the unknown-type probe cannot direct-POST sys_activity (apiMethods ['get','list']) — the sanctioned channel is activityMilestones[].type, with the objectui pin as the recorded fallback", + "ref": "#sweep-2026-08-30" } ] }, @@ -3469,6 +3524,575 @@ "ref": "claude/platform-test-checklist-ocwugl" } ] + }, + { + "id": "records-forms.field-group-visible-when", + "title": "fieldGroups[].visibleWhen section predicate: CEL accepted + normalized, retired visibleOn refused with the located prescription, derivation carries it verbatim — console section-gating leg EXPECTED-FAIL at the pinned console, where both adapters drop the key", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) — holds manage_metadata for the scratch-object PUT" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "an admin session holding `manage_metadata` (PUT /api/v1/meta/:type/:name is capability-gated, packages/rest/src/rest-route-ledger.ts:174) and an isolated boot — the item authors a scratch object", + "the stock fieldGroups declarations as authoring models: examples/app-showcase/src/data/objects/contact.object.ts:85 and semantic-zoo.object.ts:67 — NEITHER carries visibleWhen" + ], + "knownGaps": [ + "no stock showcase object carries fieldGroups[].visibleWhen (grepped: contact and semantic-zoo declare groups, no predicates) — the steps author a scratch qa_group_gate object inline, reusing the qa-feeds-disabled recipe's package-install + PUT pattern. Single consumer today, so deliberately NOT a named area recipe; promote it to one if a second item ever needs the carrier", + "clauses 4-5 (the console leg) are EXPECTED-FAIL at the sweep pins: at objectui 1e14d70 BOTH console adapters drop the key on the way from the shared spec derivation to the rendered section — see clause 4 for the exact sites. Confirm what the RUNNING console build does before scoring; if its bundle predates even the FormSection section-gating machinery (#6111/#6236/#6237), record blocked(fixture) rather than a defect" + ] + }, + "steps": [ + "boot showcase isolated; sign in as the seeded admin; keep an API token", + "author the scratch carrier: install a scratch package, then PUT /api/v1/meta/object/qa_group_gate?package= with a `kind` select (options basic|pro, basic default), two member fields declaring group: 'pro_details', and fieldGroups: [{ key: 'pro_details', label: 'Pro details', visibleWhen: \"record.kind == 'pro'\" }] — the same call sequence the area's qa-feeds-disabled recipe grounds", + "GET the object back over /api/v1/meta and read fieldGroups[0].visibleWhen — expect the parsed Expression envelope, not the bare string", + "PUT a second draft spelling the key `visibleOn` and capture the refusal body", + "run the spec pins: vitest run over packages/spec/src/data/object-strictness-batch20.test.ts and field-group-layout.test.ts", + "console leg: open a qa_group_gate record's form AND detail page; flip kind basic↔pro; watch the 'Pro details' section (header included) — screenshot each state before reading DOM", + "display-tier probe: with kind=basic (predicate FALSE), pre-fill a pro_details member value via API, open the form, save an unrelated edit, then re-read the member field over the API" + ], + "acceptance": [ + { + "clause": "authoring accepts the canonical spelling and normalizes it: a bare CEL string on fieldGroups[].visibleWhen parses, and reads back as the { dialect: 'cel', source } Expression envelope; the envelope form is accepted verbatim", + "oracle": "api", + "verify": "PUT + GET round-trip of qa_group_gate shows the normalized envelope (ExpressionInputSchema, packages/spec/src/data/object.zod.ts:1187); pinned by object-strictness-batch20.test.ts:477-503", + "evidence": "the PUT payload + the GET body's fieldGroups entry" + }, + { + "clause": "the retired spelling is refused WITH the prescription: `visibleOn` on a field group rejects at parse (strict object) and the message names `visibleWhen` and states the semantics ('FALSE hides the whole group, header included') — nothing is stored", + "oracle": "api", + "verify": "the 4xx body carries the KEY_GUIDANCE text (object.zod.ts:1155-1158); a meta re-read shows the visibleOn draft did not land", + "evidence": "the refusal body + the unchanged re-read" + }, + { + "clause": "deriveFieldGroupLayout CARRIES the predicate verbatim onto the derived section, in both shapes real metadata arrives in (bare string and envelope) — grouping stays static layout, evaluation stays the renderer's contract", + "oracle": "test", + "verify": "the visibleWhen-passthrough describe in packages/spec/src/data/field-group-layout.test.ts (from :117) is green; passthrough ruled at field-group-layout.ts:28-33", + "evidence": "test run output" + }, + { + "clause": "⚠️ EXPECTED-FAIL at the sweep pins (objectstack a286411 / objectui 1e14d70): the console renders the derived section GATED — the whole 'Pro details' section, header included, absent while kind=basic and appearing LIVE when kind flips to pro, on form and detail alike. At those pins BOTH adapters drop the key between the shared derivation and the section shape — plugin-form/src/fieldGroups.ts:60-67 (deriveFieldGroupSections copies key/label/collapse only) and plugin-detail/src/synth/buildDefaultPageSchema.ts:622-635 (deriveFieldGroupDetailSections likewise) — so an authored predicate is silently INERT in the console: the spec's 'declared = enforced on day one' claim (object.zod.ts:1184) does not hold end-to-end. Record the observed behavior; tick green ONLY on a console build demonstrably carrying the copy (read the adapters at the running build's revision, or observe the gating itself)", + "oracle": "dom", + "verify": "screenshots of both kind states on form and detail, after settle; plus the console revision consulted (stale-console-bundle discipline) — an unconditionally-rendered section at the pinned revisions is the EXPECTED result and must be recorded as such, not as a pass", + "evidence": "the four screenshots + the console revision/adapter reading the verdict rests on" + }, + { + "clause": "display-tier boundary (scored only once clause 4's gating actually exists on the running build): hiding a section gates DRAWING and nothing else — a hidden section's member values still submit untouched and its fields skip client-side validation; the server floor is unaffected. This is what separates visibleWhen from requiredWhen: visibility is display-tier, requiredness is enforcement-tier", + "oracle": "api", + "verify": "the pre-filled pro_details member value survives a save made while its section is hidden — byte-identical API re-read. Ruled semantics of the renderer's section-gating contract (objectui components/src/renderers/form/form.tsx:1241 #6236 'visibility decides what is DRAWN and nothing else'; FormSection-twin pins sectionVisibleWhen-6111.test.tsx and tabbedFormSectionPredicate-6237.test.tsx)", + "evidence": "before/after member-field reads across the hidden-section save" + } + ], + "negative": [ + "a visibleOn draft accepted 2xx — or silently stripped — is a FAIL: the tombstone exists to prescribe, not to swallow", + "a hidden section DROPPING its member values from the submit is a FAIL — visibility gates drawing only; record the display-tier/enforcement-tier boundary in the run record", + "a faulting or unknown-field predicate that produces a BLANK form or a crashed detail page is a FAIL. ⚠️ The failure DIRECTION is under-specified at head and must be recorded, not assumed: the spec docblock rules fail-CLOSED ('a faulting predicate — fail-closed — hides', object.zod.ts:1177-1182) while the renderer machinery the section-gating contract lives in documents fail-OPEN with a named diagnostic (objectui TabbedForm.tsx:62 'A broken predicate fails OPEN'; the #6236 divider replicates 'the same fail-open fallbacks'). Capture which the running build does and file the drift — do not tick either direction as the contract", + "scoring clause 4 from the VIEW-section twin is a FAIL: FormSection.visibleWhen (form-view sections, driven by form-view-gallery on task.view.ts) reaches the renderer through a different authoring surface and different plumbing than the OBJECT-level fieldGroups slot this item exists for" + ], + "traps": [ + "stale-console-bundle", + "hydration-race", + "automation-input" + ], + "source": [ + "packages/spec/src/data/object.zod.ts:1155-1158 (visibleOn tombstone prescription), :1171-1191 (visibleWhen, CEL via ExpressionInputSchema — #13030, commit 53dc739, Option-A maintainer ruling 2026-08-28)", + "packages/spec/src/data/field-group-layout.ts:28-33 (verbatim passthrough; evaluation ruled the renderer's), field-group-layout.test.ts:117+ and object-strictness-batch20.test.ts:477-503 (the pins)", + "objectui: packages/plugin-form/src/fieldGroups.ts:60-67 and packages/plugin-detail/src/synth/buildDefaultPageSchema.ts:622-635 — the two adapters that DROP the key at objectui 1e14d70 (clause 4's expected-fail); packages/components/src/renderers/form/form.tsx:1241 (#6236 section grouping contract) and packages/plugin-form/src/sectionPredicateDiagnostic.ts (wizard-arm design boundary) — the gating machinery derived sections would plug into", + "examples/app-showcase/src/data/objects/contact.object.ts:85, semantic-zoo.object.ts:67 (stock fieldGroups declarations, none with a predicate)", + "cross-ref: field-level FormField.visibleWhen is form-view-gallery's; view-section FormSection.visibleWhen is NOT this slot — this item is the object-level fieldGroups predicate #13030 re-introduced" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "initial — 3-angle sweep hit (spec-enums, routes/runtime, docs-claims): fieldGroups[].visibleWhen landed 2026-08-29 (#13030) with no checklist coverage. Authored the spec half as positive clauses (accept+normalize, tombstone prescription, derivation passthrough) and the console half as EXPECTED-FAIL after re-grounding the register against source: the register's renderer citations (ObjectForm.tsx:228/292, sectionFields.ts) are the VIEW-section FormSection.visibleWhen machinery, and at objectui 1e14d70 the two fieldGroups adapters (fieldGroups.ts, buildDefaultPageSchema.ts) drop the key, so the object-level predicate is inert in the console despite the spec's declared-equals-enforced claim", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "records-forms.field-unique-enforcement", + "title": "Field-level `unique` on the write path: colliding writes refused 409 UNIQUE_VIOLATION on both driver families, NULL-distinct, organization-scoped by default, whole-batch refusal, autonumber resync converges", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) — holds manage_metadata for the scratch-object PUT" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "an isolated dev boot on the memory driver (the stock `os serve` default) + a manage_metadata session — the item authors its own scratch object", + "scratch qa_unique object authored inline: sku text unique:true, gsku text unique:'global', nick text (no unique) — NO showcase object declares field-level unique (grepped; invoice.object.ts:100 only mentions the word in a comment)" + ], + "knownGaps": [ + "NO showcase object declares `unique` on any field — the write vectors run against the inline-authored qa_unique object; landing a unique field on the field zoo would retire this gap", + "the SQL-family live leg needs a SQL-datasource boot; without one, score clause 5's SQL half from the pinned suites (sql-driver-8577-tenant-scoped-declared-unique.test.ts drives 409 UNIQUE_VIOLATION over real REST) and record which of the two the verdict rests on", + "the per-organization scope leg needs TWO organizations with a colliding value in each — runtime-creatable via the better-auth org endpoints but not stock; record blocked(fixture) live and fall back to the unit pin (memory-unique-constraint.test.ts:229-297)", + "the autonumber-collision converge leg is not drivable over plain HTTP (it needs a stale counter planted mid-flight) — unit-pin verdict (memory-unique-constraint.test.ts:102), plus the engine's 'Autonumber collided — re-seeding' warn line if one occurs naturally" + ] + }, + "steps": [ + "boot showcase isolated on the memory driver; author qa_unique via the package-install + PUT /api/v1/meta/object/qa_unique pattern", + "POST /api/v1/data/qa_unique {name:'r1', sku:'A-1'} → 2xx; POST {name:'r2', sku:'A-1'} → capture the refusal; count rows", + "POST two rows with sku absent → both land (NULL-distinct)", + "PATCH r2 to sku:'A-1' → capture the refusal and re-read r2; PATCH r1 touching only `nick` → 2xx (no self-collision)", + "predicate update stamping ONE value onto BOTH rows (the updateMany path — client data.updateMany / the batch door where exposed; otherwise score clause 4 from its unit pin and say so) → capture the refusal and prove neither row changed", + "run the pinned suites: memory-unique-constraint.test.ts (driver-memory), sql-driver-8577-tenant-scoped-declared-unique.test.ts + sql-driver-declared-index-organization-respelling.test.ts (SQL family)" + ], + "acceptance": [ + { + "clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), message naming object.field and stating 'No record was written', with NO driver prefix — and the row count is unchanged", + "oracle": "api", + "verify": "the refusal body + a post-refusal count; wire identity single-sourced at packages/drivers/driver-memory/src/memory-unique-constraint.ts:92-106 (uniqueViolationError :203-216)", + "evidence": "refusal response + before/after counts" + }, + { + "clause": "NULLs are NULL-DISTINCT, exactly as under SQL UNIQUE: two rows whose unique field carries no value both land — an optional unique column does not refuse its second row", + "oracle": "api", + "verify": "both no-sku POSTs return 2xx and both rows read back (module note memory-unique-constraint.ts:70-77; pin :189)", + "evidence": "the two creates + the list read" + }, + { + "clause": "the update paths are constrained too: an update onto a taken value is refused and the row keeps its old value; a row never collides with ITSELF (an update leaving the unique field alone passes)", + "oracle": "api", + "verify": "the PATCH refusal + the unchanged re-read; the self-update 2xx (pins memory-unique-constraint.test.ts:135,141)", + "evidence": "both responses + the re-read" + }, + { + "clause": "updateMany refuses the WHOLE batch BEFORE mutating anything — a predicate update stamping one value onto two rows leaves zero rows changed, never a half-applied batch", + "oracle": "api", + "verify": "post-refusal re-reads of every matched row are byte-identical to before (memory-driver.ts:611,627-631 'Prepare and CHECK every row before mutating any of them'; pin :155). If no REST surface reaches updateMany on the boot under test, score from the pin and record the substitution", + "evidence": "the refusal + full before/after row reads" + }, + { + "clause": "the scope matrix matches ADR-0120 D1/D3 on BOTH families: bare `unique: true` ≡ 'organization' (per-tenant where a tenant column exists; NULL-organization rows fold to ONE bucket), 'global' is platform-wide even with a tenant column — and the SQL family materializes the same rule via uniqueIndexesFromFields (driver-sql/src/schema-drift.ts:1599), answering the same 409 UNIQUE_VIOLATION over REST", + "oracle": "test", + "verify": "memory-unique-constraint.test.ts:229-297 (scope legs) + sql-driver-8577-tenant-scoped-declared-unique.test.ts green; live two-org probe only if two organizations are provisioned (knownGaps)", + "evidence": "test output (+ live scope probe where run)" + }, + { + "clause": "an autonumber collision CONVERGES instead of landing a duplicate business number: the driver's refusal is recognisable to the engine, which drops the stale counter, re-seeds from the store and re-issues (bounded attempts), so the insert succeeds with a fresh number", + "oracle": "test", + "verify": "memory-unique-constraint.test.ts:102 ('the refusal is recognisable to the ENGINE, so the autonumber resync converges'); engine path createWithAutonumberResync, packages/objectql/src/engine.ts:4488-4531", + "evidence": "test output" + } + ], + "negative": [ + "a colliding write landing 2xx with both rows readable is a FAIL — that is the exact pre-#13197 memory-driver behavior (create was a table.push) this item exists to keep closed", + "a refusal message carrying a [driver-memory] prefix is a FAIL — the wire identity is the SQL family's, driver-anonymous by contract", + "a half-applied updateMany (some rows stamped before the refusal) is a FAIL", + "two organizations' identical values colliding under bare `unique: true` is a FAIL: bare true is the positional spelling of 'organization', NOT 'global' — getting it backwards builds the cross-tenant existence oracle ADR-0120 D1 exists to remove" + ], + "traps": [ + "wrong-persona" + ], + "automated": { + "kind": "unit", + "ref": "packages/drivers/driver-memory/src/memory-unique-constraint.test.ts (refusal shape, NULL-distinct, every write path, scope matrix, autonumber converge, boundary cases); packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts + sql-driver-declared-index-organization-respelling.test.ts (SQL family, 409 UNIQUE_VIOLATION over real REST)" + }, + "source": [ + "packages/drivers/driver-memory/src/memory-unique-constraint.ts (#13197/#13249, commit 56c093c — single judgment point; scope table read off driver-sql, arm for arm)", + "packages/drivers/driver-memory/src/memory-driver.ts:611 (updateMany check-before-mutate)", + "packages/drivers/driver-sql/src/schema-drift.ts:1599 (uniqueIndexesFromFields, ADR-0120 D1/D3)", + "packages/objectql/src/engine.ts:4488 (createWithAutonumberResync — the branch the enforcement makes reachable)", + "content/docs/capabilities/data.mdx:42 ('Uniqueness … per-field switches' — the docs claim this item makes testable)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "initial — 2-angle sweep hit (routes/runtime, docs-claims): field-level unique enforcement landed on the memory driver 2026-08-30 (#13197/#13249) closing a declared-but-unenforced constraint; no checklist item drove the write path on either family. Scope legs and autonumber converge rest on the landing's own pins; live HTTP vectors run against an inline-authored scratch object because no showcase object declares unique", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "records-forms.delete-behavior-matrix", + "title": "deleteBehavior matrix: set_null clears / member-removes, master_detail cascades, restrict answers 409 DELETE_RESTRICTED with the cascade remedy, required-FK set_null escalates to restrict", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "cascade: showcase_invoice_line.invoice (master_detail, explicit deleteBehavior:'cascade', invoice.object.ts:184) and expense-report lines (expense-report.object.ts:134)", + "set_null scalar: showcase_contact.account (optional lookup, default set_null — contact.object.ts:52)", + "multi-value: showcase_field_zoo.f_lookups (lookup multiple:true → showcase_account, field-zoo.object.ts:106)", + "required-FK escalation: showcase_invoice.account (lookup required:true with DEFAULT deleteBehavior — invoice.object.ts:69-71): deleting an account that invoices reference escalates set_null→restrict on stock seeds" + ], + "knownGaps": [ + "no showcase field declares an EXPLICIT deleteBehavior:'restrict' — the stock escalation path (invoice.account) exercises the same DELETE_RESTRICTED refusal but enters it via required+set_null; to drive the authored-restrict arm distinctly, author a scratch child with deleteBehavior:'restrict' (the qa-feeds-disabled PUT pattern) or record the substitution", + "the master_detail explicit-set_null coercion (clause 6) is not authorable at head — FieldSchema parse-REJECTS it (#9689) — so the around-the-parse-seam log leg is a unit-pin verdict (engine-cascade-delete.test.ts), not an HTTP-drivable one" + ] + }, + "variants": [ + "set_null (lookup default) — parent delete clears the scalar FK to null", + "set_null on a multi-value lookup — removes the deleted MEMBER and writes the remainder, never clears the slot (#9438)", + "cascade (master_detail default; authorable on lookup) — children deleted recursively through the public delete path, so their own cascades/hooks/events fire", + "restrict — parent delete refused 409 DELETE_RESTRICTED, remedy naming deleteBehavior:'cascade'", + "escalation — required scalar FK + resolved set_null becomes restrict; multi-value narrows per-row to exactly the rows the removal would EMPTY (#9688/#9447)", + "master_detail + authored set_null — parse-REJECTED (#9689); a row arriving around the parse seam coerces to cascade with the loud [cascade-delete] error log", + "NOT enumSource-pinnable: deleteBehavior is an inline z.enum(['set_null','cascade','restrict']) inside FieldSchema (field.zod.ts:1071) with no named export — 3 members hand-pinned here; a fourth member will not trip a pin and must be caught by re-reading the enum" + ], + "steps": [ + "boot showcase isolated; admin API token; work on rows created BY THIS RUN, not the seeds (the cascade probes destroy their parents)", + "set_null scalar: create an account + a contact pointing at it; DELETE the account; re-read the contact", + "multi-value: create two accounts + a field-zoo row with f_lookups = [both]; DELETE one account; re-read the row", + "cascade: create an invoice with two lines; DELETE the invoice; list the lines", + "escalation/restrict: create an account + an invoice referencing it (account is required); DELETE the account; capture the refusal envelope", + "author-restrict arm (scratch, per knownGaps): scratch child with deleteBehavior:'restrict' → same DELETE probe", + "run the engine pins: vitest run over packages/objectql/src/engine-cascade-delete.test.ts (+ -atomic, -multivalue-probe siblings)" + ], + "acceptance": [ + { + "clause": "set_null (the lookup default): deleting the referenced parent clears the child's scalar FK to null — the child row survives, the delete succeeds", + "oracle": "api", + "verify": "post-delete re-read of the contact shows account: null (defaults ruled at packages/objectql/src/engine.ts:11377-11390)", + "evidence": "the delete response + the child re-read" + }, + { + "clause": "multi-value set_null removes the deleted MEMBER and writes the REMAINDER — never [] while live members remain, never the untouched original", + "oracle": "api", + "verify": "f_lookups re-read shows exactly the surviving account id (#9438; empty-array-as-no-link semantics engine.ts:2054-2064)", + "evidence": "before/after f_lookups reads" + }, + { + "clause": "master_detail cascades by default and via explicit 'cascade': deleting the invoice deletes its lines through the PUBLIC delete path (children's own hooks/cascades fire), and the lines are authoritatively gone", + "oracle": "api", + "verify": "post-delete line list returns 0 rows (recursion ruled at engine.ts:11722-11729 'Recurse via the public delete')", + "evidence": "the empty line read" + }, + { + "clause": "restrict refuses with the actionable envelope: 409, code DELETE_RESTRICTED, dependentObject named UNCONDITIONALLY, developerMessage carrying the deleteBehavior:'cascade' remedy — and dependentCount present only when the caller's own identity would see the rows (#12166)", + "oracle": "api", + "verify": "the refusal body against engine.ts:11703-11720 (code/status/dependentObject/developerMessage; dependentCountIsDisclosable gate). As the admin the count should be disclosed; a lesser persona's refusal must name the object but MAY omit the count — record which shape was observed", + "evidence": "the refusal envelope" + }, + { + "clause": "required scalar FK escalates: an invoice's required account lookup (resolved set_null) refuses the account delete as DELETE_RESTRICTED naming the PARENT delete — never the child's misleading ' is required' 400 (a validation error about an object the caller never wrote)", + "oracle": "api", + "verify": "DELETE of a referenced account answers 409 DELETE_RESTRICTED (escalation engine.ts:11424-11480: requiredSetNull && !multiValued → restrict; multi-value deferred and narrowed per-row to sets the removal would empty, #9688/#9447)", + "evidence": "the refusal + a control delete of an UNreferenced account succeeding" + }, + { + "clause": "the master_detail/set_null contradiction is closed at BOTH seams: FieldSchema parse-rejects the authored combination (#9689), and a row that reaches the engine around the parse seam is coerced to cascade with the loud [cascade-delete] error log naming the field, the coercion and the three remedies", + "oracle": "test", + "verify": "a PUT authoring deleteBehavior:'set_null' on a master_detail field is refused at parse; the seam-side coercion + log is pinned by engine-cascade-delete.test.ts (log text at engine.ts:11410-11423)", + "evidence": "the parse refusal + the pin's output" + } + ], + "negative": [ + "a restrict/escalation refusal surfacing as the child's 'field is required' 400 is a FAIL — the #9688 deferral exists precisely so the caller is told DELETE_RESTRICTED about the record they asked to delete", + "a multi-value member removal that clears the WHOLE slot (or leaves the dead id in place) is a FAIL", + "a cascade that leaves orphan children readable — or deletes them through a driver shortcut that skips their own hooks — is a FAIL", + "a dependentCount of 0 on a refusal is a FAIL (there is at least one dependent or the branch is unreachable — the envelope omits the key instead of lying, engine.ts:11713-11718)" + ], + "traps": [ + "destructive-in-place", + "wrong-persona" + ], + "automated": { + "kind": "unit", + "ref": "packages/objectql/src/engine-cascade-delete.test.ts (+ engine-cascade-delete-atomic.test.ts, engine-cascade-delete-multivalue-probe.test.ts) — defaults, coercion+log, escalation, per-row multi-value narrowing" + }, + "source": [ + "packages/spec/src/data/field.zod.ts:1071 (deleteBehavior inline enum; #9689 parse rejection, #9784 default materialization)", + "packages/objectql/src/engine.ts:11377-11480 (defaults, coercion + [cascade-delete] log, required-FK escalation incl. #9688 multi-value deferral), :11655-11729 (DELETE_RESTRICTED envelope, #12166 count disclosure, cascade recursion)", + "examples/app-showcase/src/data/objects/invoice.object.ts:69-71,184; contact.object.ts:52; field-zoo.object.ts:106; expense-report.object.ts:134 (the four stock arms)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "initial — sweep gap (spec-enums angle): all three deleteBehavior members plus the escalation/coercion seams were enforced and pinned engine-side but never driven over HTTP by any item. Register corrected against source: restrict is NOT fixture-blocked on stock seeds — invoice.account (required lookup) reaches the same DELETE_RESTRICTED refusal via the #9688 escalation, so only the authored-restrict spelling needs a scratch child", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "records-forms.record-clone-contract", + "title": "Clone contract edges: enable.clone gate both ways (403 CLONE_DISABLED), overrides win but cannot forge readonly, bare-201 body per CloneDataResponseSchema, internal fields stripped from the response", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) — holds manage_metadata for the scratch-object PUT" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_account seeds (the default-ON control — enable.clone is opt-out, object.zod.ts feeds/clone family default true)", + "a scratch qa_noclone object authored inline with enable: { clone: false } (no showcase object declares it — same PUT pattern as qa-feeds-disabled) — the gate's refusal side", + "a readonly-carrying object for the forge probe: any object with a readonly field (e.g. a scratch field, or showcase approval-status shapes where present)" + ], + "knownGaps": [ + "no showcase object declares enable.clone:false — the 403 side runs against the inline-authored qa_noclone scratch object", + "no showcase object exposes an `internal: true` field to observe clause 4 live — score it from the conformance pins (metadata-protocol search-clone-schema-conformance.test.ts + the #7823 strip) and record the substitution" + ] + }, + "steps": [ + "boot showcase isolated; admin token; author qa_noclone ({ enable: { clone: false } }, one text field) and create one row in it", + "POST /api/v1/data/qa_noclone//clone → capture the refusal; then the control: clone a showcase_account row → 201", + "clone WITH overrides: POST /api/v1/data/showcase_account//clone {overrides:{name:'os-qa-clone-override'}} — and include a readonly/system key in overrides (e.g. created_by, or a readonly field where one exists)", + "diff the 201 body against CloneDataResponseSchema expectations: top-level {object, id, sourceId, record} with NO envelope wrapper", + "clone an unknown id → capture the 404; clone on an unregistered object name → capture the shared registration refusal" + ], + "acceptance": [ + { + "clause": "the enable.clone gate holds BOTH ways: an explicit enable.clone:false object refuses with 403 code CLONE_DISABLED naming the object, and the default-on control (showcase_account, flag absent) answers 201 — absent block/flag means enabled, only explicit false blocks", + "oracle": "api", + "verify": "the qa_noclone refusal ({code:'CLONE_DISABLED', status:403} thrown at packages/metadata-protocol/src/protocol.ts:9655-9662) + the account control 201; no qa_noclone row count change", + "evidence": "both responses + the unchanged count" + }, + { + "clause": "overrides WIN over copied business values — the override name lands on the clone — but cannot FORGE protected columns: stripReadonlyForInsert runs AFTER Object.assign(data, overrides) (protocol.ts:9690-9699), so a readonly/engine-owned key smuggled through overrides is dropped and re-derived, same as #3043's carried-over case", + "oracle": "api", + "verify": "the clone's re-read shows the override name AND the forged key re-derived (defaultValue / fresh audit stamp), not the smuggled value", + "evidence": "the overrides payload + the clone re-read diff" + }, + { + "clause": "the 201 body is BARE — res.status(201).json(result) with no envelope — and matches CloneDataResponseSchema: {object, id (new ≠ sourceId), sourceId, record} (#11924)", + "oracle": "api", + "verify": "top-level keys of the raw response against the rest-route-ledger row (packages/rest/src/rest-route-ledger.ts:283-285) — no {data:…}/{result:…} wrapper", + "evidence": "the raw 201 body" + }, + { + "clause": "internal fields never ride the 201: omitInternalFieldsFromWriteResponse runs on the insert result (#7823, protocol.ts:9701-9707) — the write-response strip that guards the clone body the same as createData's", + "oracle": "test", + "verify": "metadata-protocol's search-clone-schema-conformance.test.ts (parses the real cloneData producer) + the #7823 strip suite green; live probe only where an internal-carrying object exists (knownGaps)", + "evidence": "test output (+ live body scan where run)" + }, + { + "clause": "the not-found refusals are the shared data-plane ones: an unknown id answers 404 RECORD_NOT_FOUND (also the RLS-invisible shape — crud-roundtrip clause 8 drives that side); an unregistered object answers the shared assertObjectRegistered refusal (#3770), not a bespoke clone error", + "oracle": "api", + "verify": "both refusal envelopes; protocol.ts:9648-9652", + "evidence": "the two refusals" + } + ], + "negative": [ + "a clone of an enable.clone:false object succeeding is a FAIL; equally, a DEFAULT object refusing (misreading absent-as-disabled) is a FAIL — the gate blocks on explicit false only", + "an overrides payload minting a readonly value (an already-approved clone) is a FAIL — #3043's exact shape, entering through the overrides door instead of the copy", + "an enveloped 201 body is a FAIL — clients bind the bare shape (#11924)", + "NOTE for the run record, not a clause: 'one-click record cloning' (content/docs/capabilities/integrations.mdx:22) has NO objectui caller of data.clone at objectui 1e14d70 — the surface is API-only today; the Studio switch that AUTHORS enable.clone does exist (objectui app-shell/src/views/studio-design/ObjectSettingsPanel.tsx:118). Docs-drift row is the orchestrator's to file" + ], + "traps": [ + "wrong-persona" + ], + "automated": { + "kind": "api", + "ref": "packages/metadata-protocol — search-clone-schema-conformance.test.ts (parses the real cloneData producer); packages/rest — search-clone-route-schema-conformance.test.ts (drives the live mount); named on the ledger row rest-route-ledger.ts:283-285" + }, + "source": [ + "packages/metadata-protocol/src/protocol.ts:9648-9712 (cloneData: registration gate #3770, CLONE_DISABLED, findOne-in-context, CLONE_STRIP_FIELDS :1391 + system/autonumber/formula/summary strip, overrides, stripReadonlyForInsert #3043, omitInternalFieldsFromWriteResponse #7823)", + "packages/rest/src/rest-route-ledger.ts:283-285 (POST /api/v1/data/:object/:id/clone, client data.clone, bare-201 note #11924)", + "objectui: packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx:118 (Studio authors the enable.clone opt-out switch)", + "cross-ref: the clone HAPPY path + engine-column re-derivation + RLS-gated 404 are crud-roundtrip clauses 7-8 — this item drives only the contract edges that item does not, and deliberately re-states none of its oracles" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "initial — sweep gap (docs-claims angle): crud-roundtrip covers the clone happy path and RLS gate, but the CLONE_DISABLED refusal was only ever NAMED there, and the overrides-vs-readonly ordering, bare-201 body contract and #7823 response strip had no coverage anywhere. Also records that the docs' 'one-click cloning' has no console caller — API-only surface at the sweep pins", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "records-forms.field-history-tracking", + "title": "Per-field trackHistory: a tracked edit lands the ADR-0052 §5b old→new timeline summary with display values, an untracked edit does not, and object-level enable.trackHistory gates the History tab and derived history op both ways", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_account: enable.trackHistory:true (account.object.ts:49) + per-field trackHistory on industry/status (:55,:78) — the BOTH-halves fixture", + "showcase_task: per-field trackHistory on status/priority (task.object.ts:49,60) with NO object-level enable block — the natural negative for the tab/op gating" + ], + "knownGaps": [ + "no showcase object declares trackHistory:true on a REFERENCE field, so the referenced-record-title rendering (clause 5) is a unit-pin verdict (audit-lookup-summary.test.ts) unless a scratch tracked lookup is authored — record which the verdict rests on" + ] + }, + "steps": [ + "boot showcase isolated; sign in as admin; open a seeded account record", + "edit the TRACKED select `industry` (e.g. Technology → Finance); save; read the record's sys_activity rows over the API and open the timeline", + "edit an UNTRACKED field (e.g. website) alone; save; re-read sys_activity for the record", + "open the account's History tab; then open a seeded task's detail and look for a History tab", + "GET /api/v1/auth/me/permissions and compare apiOperations for showcase_account vs showcase_task" + ], + "acceptance": [ + { + "clause": "a tracked-field edit lands the DECLARATIVE activity: the update's sys_activity row carries the §5b rendered summary 'Industry: ' — field LABEL and select OPTION labels, localized through the write-time translator, not raw keys/values", + "oracle": "api", + "verify": "the sys_activity read shows the old→new summary with display values (renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts:478-560; #7230 label + #7289 option-value localization); the timeline renders the same row", + "evidence": "the sys_activity row + a timeline screenshot" + }, + { + "clause": "an UNtracked-field edit lands NO tracked-change summary — the activity (if any) falls back to the generic 'Updated ' with no per-field old→new for the untracked key (renderTrackedChangeSummary returns null when no tracked field changed)", + "oracle": "api", + "verify": "post-edit sys_activity for the website-only edit carries no field-diff summary naming website", + "evidence": "the sys_activity read" + }, + { + "clause": "the History TAB gates on OBJECT-level enable.trackHistory, both ways: the account detail renders the tab (flag true) and the task detail does NOT (no enable block — per-field flags alone do not summon it)", + "oracle": "dom", + "verify": "screenshot both detail pages after settle; gating ruled at objectui RecordDetailView.tsx:1216-1231 (trackHistory must be explicitly true)", + "evidence": "both screenshots" + }, + { + "clause": "the derived `history` API operation follows get ∧ enable.trackHistory, both ways: present in showcase_account's effective operation vocabulary, absent from showcase_task's", + "oracle": "api", + "verify": "GET /api/v1/auth/me/permissions apiOperations (or a 405 allowed array) — derivation ruled at packages/spec/src/data/api-derivation.ts:142 ({ all: ['get'], flag: trackHistory })", + "evidence": "the two operation lists" + }, + { + "clause": "a tracked REFERENCE field renders TITLES, not raw ids, on both sides of the change — and the title reads are PLANNED (zero reads unless a tracked reference actually changed, the #6977 read-budget discipline)", + "oracle": "test", + "verify": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts:304-320 green (planTrackedLookupReads audit-writers.ts:454-476, resolution :895-935); live only via a scratch tracked lookup (knownGaps)", + "evidence": "test output (+ live summary where authored)" + } + ], + "negative": [ + "an untracked edit producing an old→new diff row is a FAIL — trackHistory is opt-in per field (spec default false, object.zod.ts:204 family note)", + "a task detail rendering a History tab is a FAIL — per-field flags without the object-level opt-in must not summon it", + "a tracked select rendering raw option VALUES ('in_progress') instead of labels is a FAIL (#7289 closed exactly that)", + "cross-ref guard: do NOT re-score crud-roundtrip clause 5 (History-tab display quality on the create/update pair) here — this item owns the tracked/untracked boundary and the two gating directions, that one owns the tab's rendering contract" + ], + "traps": [ + "hydration-race", + "eventual-consistency" + ], + "automated": { + "kind": "unit", + "ref": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts (reference titles + read plan); audit-option-label-summary.test.ts (option labels); objectui e2e/live/record-history-display.spec.ts (tab display contract, cited by crud-roundtrip)" + }, + "source": [ + "packages/plugins/plugin-audit/src/audit-writers.ts:454-476 (planTrackedLookupReads), :478-560 (renderTrackedChangeSummary — ADR-0052 §5b, #7230/#7289 localization), :895-935 (zero-reads-by-default plan)", + "packages/spec/src/data/object.zod.ts:48 (history derives from get ∧ trackHistory), :198-205 (opt-in default false); packages/spec/src/data/api-derivation.ts:142", + "objectui: packages/app-shell/src/views/RecordDetailView.tsx:1216-1231 (History tab three-precondition gate)", + "examples/app-showcase/src/data/objects/account.object.ts:43-49 (the deliberate both-halves fixture + its comment ruling the tab gate), task.object.ts:49,60" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "initial — sweep gap (docs-claims angle): 'full change history when tracking is on' (capabilities/data.mdx) had only the History-tab display clause in crud-roundtrip; nothing drove the per-field tracked/untracked boundary, the §5b declarative summary, or the enable.trackHistory gating of tab and derived history op. The account/task pair makes both gating directions stock-drivable", + "ref": "#sweep-2026-08-30" + } + ] + }, + { + "id": "records-forms.import-transform-matrix", + "title": "Import mapping TransformType matrix: each of the 7 declared transforms does exactly its documented conversion, javascript is refused loudly at the door (no sandbox), format mismatch is a named 400", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) — holds manage_metadata for scratch mapping PUTs" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped mapping showcase_inquiry_feed (examples/app-showcase/src/data/mappings/index.ts:20-39) — drives `none` (transform absent → direct copy) and `map` (Channel valueMap) on stock fixtures", + "scratch mappings for the remaining transforms, authored over the metadata channel (PUT /api/v1/meta/mapping/) against showcase_inquiry or a scratch object" + ], + "knownGaps": [ + "only none + map are stock-drivable (the shipped mapping); constant / lookup / split / join / javascript each need a scratch mapping authored inline — score those legs blocked(fixture) if metadata authoring is unavailable on the boot under test", + "the unknown-transform runtime refusal (import-mapping.ts:154-159) guards rows that arrive AROUND the parse seam — spec parse rejects an unknown value at authoring, so it is not HTTP-drivable end-to-end; it lives in the negative list, not a clause" + ] + }, + "variants": [ + "none — direct copy, also the default when `transform` is absent", + "constant — target takes params.value, source ignored", + "lookup — value copied verbatim by the transform; FK resolution happens downstream in the import pipeline", + "split — one source string split by params.separator into MULTIPLE targets, parts trimmed", + "join — multiple sources joined by the separator, null/undefined/'' members dropped", + "javascript — declared by the spec, REFUSED by the import door: 400, no server-side sandbox (#2611)", + "map — params.valueMap translates known values; an unmapped raw value passes through UNCHANGED" + ], + "steps": [ + "POST /api/v1/data/showcase_inquiry/import with mappingName 'showcase_inquiry_feed' and a CSV whose Channel column carries one mapped code ('Webform') and one unmapped string; read back the created rows", + "author scratch mappings (one per remaining transform) over PUT /api/v1/meta/mapping/; import a vector file through each and read back the rows", + "author a scratch mapping declaring transform 'javascript' on one column; POST an import through it and capture the refusal", + "POST a JSON payload through a sourceFormat:'csv' mapping and capture the refusal; POST an xlsx payload through the same and confirm it is ACCEPTED (tabular-compatible)" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: each supported transform lands exactly its documented output on the created rows — none copies; constant writes params.value ignoring the source; map translates valueMap hits AND passes an unmapped raw value through unchanged; split distributes trimmed parts across its target list; join concatenates with the separator dropping empty members", + "oracle": "api", + "verify": "per-variant row re-reads against the switch arms in packages/rest/src/import-mapping.ts:112-152 — one observed conversion per variant, none inferred from a sibling", + "evidence": "the vector files + per-variant row reads" + }, + { + "clause": "javascript is declared-but-refused, LOUDLY and BEFORE any row lands: a mapping carrying transform 'javascript' answers 400 code UNSUPPORTED_TRANSFORM whose message names the missing server-side sandbox and framework#2611 — and zero rows are created", + "oracle": "api", + "verify": "the refusal (import-mapping.ts:96-101 — checked per-entry before applyMappingToRows runs) + an unchanged target count; the spec deliberately still admits the value (TransformType), so the DOOR is the contract", + "evidence": "the 400 body + before/after counts" + }, + { + "clause": "lookup copies the raw value for DOWNSTREAM resolution: the transform itself moves the source value verbatim (import-mapping.ts:124-127 'lookup values resolve downstream via metaMap'), and the created row's reference field holds a resolved real id, not the foreign display value", + "oracle": "api", + "verify": "the created row's FK re-read; a vector naming a nonexistent target must NOT land a dangling reference (the pipeline's own reference gate)", + "evidence": "the row read + the dangling-vector refusal/report" + }, + { + "clause": "the format guard is a named refusal: a JSON payload through a csv-declared mapping answers 400 MAPPING_FORMAT_MISMATCH naming both formats; an xlsx payload through the same mapping is accepted (csv-declared applies to tabular xlsx too)", + "oracle": "api", + "verify": "both responses against import-mapping.ts:85-94", + "evidence": "the refusal + the xlsx acceptance" + } + ], + "negative": [ + "a javascript-transform import that silently SKIPS the column (or worse, executes anything) is a FAIL — the refusal must be loud, total and row-free", + "a map transform DROPPING an unmapped value (instead of passing it through unchanged) is a FAIL — the passthrough is the documented arm", + "an unknown transform string reaching applyMappingToRows answers 400 UNSUPPORTED_TRANSFORM 'unknown transform' (import-mapping.ts:154-159) — around-the-parse-seam guard, not HTTP-drivable end-to-end (spec parse rejects it at authoring); if ever observed answering 200, that is a FAIL", + "cross-ref guard: header mapping, idempotent re-import and the unknown-mappingName refusal are named-import-mapping's clauses — do not re-score them here" + ], + "traps": [ + "seed-data-thin", + "silent-coercion" + ], + "enumSource": { + "file": "packages/spec/src/data/mapping.zod.ts", + "export": "TransformType", + "expect": 7 + }, + "source": [ + "packages/spec/src/data/mapping.zod.ts:157-165 (TransformType — none/constant/lookup/split/join/javascript/map, 7 members, named export pinned above)", + "packages/rest/src/import-mapping.ts:85-101 (format guard + the javascript refusal naming #2611), :112-159 (the per-transform switch + around-the-seam unknown-transform refusal)", + "examples/app-showcase/src/data/mappings/index.ts:20-39 (showcase_inquiry_feed — the stock none+map carrier)", + "cross-ref: records-forms.named-import-mapping (header mapping, idempotence, unknown-name refusal — kept there; this item is the transform axis)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-30", + "change": "initial — sweep gap (spec-enums angle): TransformType's 7 members had no per-variant coverage; named-import-mapping drives only the default copy + upsert path. Authored as a sibling item rather than widening that one (different axis, different fixtures), with the enumSource pin so an 8th transform trips VARIANTS STALE", + "ref": "#sweep-2026-08-30" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 9ea0c1f79b..c8f4ce1dc2 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -567,6 +567,103 @@ { "revision": 2, "date": "2026-08-11", "change": "two text corrections from run #7629, no clause substance changed. (1) The RLS-parity fixture carried the same stale invoice count as search.rls-both-personas — the seed ships 12 rows INV-1001..INV-1012, not 8; INV-1003's owner (linus) is now named so the invisible-row premise is checkable in place. (2) knownGaps records that this path inherits the declared-case-insensitive contract and the #7641 executor gap, since searchAll also emits `$contains` — without it a run scores a case-driven miss as a palette defect. Direction is per the #4706 Q2 = A ruling: case-insensitive is the declared truth, so the checklist reconciles toward the declaration and #7641 owns the product side", "ref": "#7647" }, { "revision": 3, "date": "2026-08-12", "change": "the executor gap this item's third knownGap recorded is CLOSED, so the gap flips to past tense. #7641 found searchAll to be a SECOND producer of search clauses with the same declared≠enforced defect — metadata-protocol/src/protocol.ts built its AND-of-OR from `$contains` under a comment asserting `$contains` was the case-insensitive operator — and moved it onto `$icontains` in the same PR as search-filter.ts. No clause substance changed; a case-varying miss on a current build is now a real palette/UI defect rather than an expected executor FAIL, which is the scoring instruction this revision corrects", "ref": "#7641" } ] + }, + { + "id": "search.command-palette-navigation", + "title": "The ⌘K palette is a NAVIGATOR beyond record search: the active app's nav corpus renders grouped (Objects/Dashboards/Pages/Reports), Enter navigates each type, app-switch and theme commands dispatch for real, and visibility-gated / inactive entries stay absent", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase app's navigation, which declares ALL FOUR palette-grouped leaf types (examples/app-showcase/src/ui/apps/index.ts): object items (:50-65, e.g. nav_projects/nav_tasks), page items (:30-40, e.g. nav_capability_map), dashboard items (:92-95, e.g. nav_ops → showcase_ops_dashboard), report items (:102-105, e.g. nav_report_summary) — plus nested groups, so the flattenNavigation leaf-only contract is exercisable", + "more than one registered active app (a stock boot registers showcase_app + setup + account — RUNNER.md environment facts), so the Switch-app group renders", + "the objectui console app-shell (CommandPalette + CommandPaletteProvider)" + ], + "knownGaps": [ + "NO showcase nav item declares a visibility expression (`visible`/`visibleOn` — grepped apps/index.ts), so the gate-hidden clause's HIDDEN side has no stock fixture. App metadata is code-authored and NOT in the org-overridable runtime-write set (view/dashboard/report/translation/email_template only — RUNNER.md), so the fixture is a scratch-app nav item carrying `visible: 'false'` (or a persona-dependent CEL predicate), not a runtime edit; without it, score that side blocked(fixture) and record it", + "the /_console bundle is vendored and may be stale — verify palette behavior against current objectui app-shell or a fresh build (stale-console-bundle, RUNNER §2)" + ] + }, + "variants": [ + "nav type: object (→ resolved object route)", + "nav type: dashboard (→ dashboard route)", + "nav type: page (→ page route)", + "nav type: report (→ report route)", + "Switch app (onAppChange — active apps only)", + "theme command (light / dark / system — setTheme dispatch)", + "Open full search (→ /search)" + ], + "steps": [ + "sign in as admin, open the palette (⌘K), screenshot the settled overlay (data-testid overlay:command-palette)", + "type a term matching nav labels (e.g. 'hours' hits the report items; 'tasks' hits objects) and screenshot: nav hits must render under their TYPE headings (Objects / Dashboards / Pages / Reports) — read the DOM only after the screenshot", + "confirm the corpus is the ACTIVE app's flattened navigation: a leaf inside a nav GROUP (e.g. the grouped showcase items) appears as a flat entry, and another app's nav items do NOT appear while showcase is active", + "Enter one item PER TYPE (ref-targeted select, never a coordinate click): object → its list route, dashboard/page/report → their routes; confirm each destination page renders and the palette closed", + "open the Switch-app group: it lists the OTHER active apps; select one and confirm the active app changes (URL + the palette's nav corpus now belongs to the target app); switch back", + "run a theme command ('Dark theme'): the root theme class flips immediately and the palette closes — the command dispatched, not just closed (persistence itself is platform-core.theme-mode-persistence's, not re-proven here)", + "select 'Open full search' and confirm navigation to /apps//search (deep coverage of that page stays with search.console-global-search)", + "the gate-hidden probe (blocked on stock seeds — see knownGaps): with a scratch nav item whose visibility predicate evaluates false, confirm it appears in NO group while a sibling with a true predicate appears; capture both sides", + "capture the browser console for the whole session" + ], + "acceptance": [ + { + "clause": "the nav corpus renders grouped by TYPE from the active app's flattened navigation: object/dashboard/page/report leaves each under their own heading, nested nav groups flattened to leaves, no other app's items mixed in", + "oracle": "dom", + "verify": "post-screenshot DOM shows the four headings with the showcase fixtures under the right ones (flattenNavigation + the per-type CommandGroup blocks, CommandPalette.tsx:255-328,391-402); a grouped leaf (e.g. a nested showcase nav child) is present as a flat entry", + "evidence": "the grouped-palette screenshot + heading/entry DOM" + }, + { + "clause": "Enter navigates PER TYPE: selecting an object/dashboard/page/report entry routes to its resolveHref destination and the page renders — one verification per variant, no type inferred from a sibling", + "oracle": "screenshot", + "verify": "four post-Enter screenshots, each at the expected route with the destination surface rendered (onSelect → runCommand(navigate(resolveHref(item, baseUrl, templateContext).href)))", + "evidence": "the four navigation captures (URL + screenshot)" + }, + { + "clause": "global commands DISPATCH, not just close: a theme command applies the theme immediately (root class flips before any reload) and Switch-app changes the active app — the palette's own corpus re-derives from the target app on reopen", + "oracle": "dom", + "verify": "post-command DOM shows the theme class applied (setTheme via useTheme) and, after an app switch, the reopened palette lists the target app's nav items (onAppChange wired through ConsoleLayout)", + "evidence": "the before/after DOM reads + reopened-palette screenshot" + }, + { + "clause": "the Switch-app group is honest: it renders only when more than one ACTIVE app exists, lists only apps with active !== false, and marks the current app — an inactive app never appears as a switch target", + "oracle": "dom", + "verify": "the group lists exactly the stock active apps (showcase_app/setup/account shapes) with the 'current' marker on the active one (CommandPalette.tsx:331-355 filter a.active !== false)", + "evidence": "the switch-group screenshot + app list" + }, + { + "clause": "visibility-gated entries are ABSENT — both sides: a nav item whose visible/visibleOn predicate evaluates false for the current context appears in NO palette group, while a true-predicate sibling appears (the same evaluateVisibility filter the sidebar honours, so palette and sidebar cannot disagree about what exists)", + "oracle": "dom", + "verify": "with the scratch fixture (knownGaps): the false-predicate item absent from every group, the true-predicate item present (CommandPalette.tsx:85-87 filter over flattenNavigation); on stock seeds score blocked(fixture)", + "evidence": "the paired present/absent DOM reads" + }, + { + "clause": "'Open full search' hands off to the /search page: selecting it navigates to /search with the palette closed — the boundary to search.console-global-search, which owns that page's behavior", + "oracle": "screenshot", + "verify": "post-select the SearchResultsPage route is loaded (CommandPalette.tsx:374-385)", + "evidence": "the handoff capture" + } + ], + "negative": [ + "a nav type rendered FLAT with no heading (or under the wrong heading) is a FAIL of the grouped contract — same class as the #3371 record-hit grouping", + "Enter closing the palette WITHOUT navigating is a FAIL — but rule out the automation-input trap first (ref-targeted select, not a coordinate click)", + "a false-predicate nav item appearing in the palette while the sidebar hides it is a FAIL: the palette must not be a side door around visibility expressions (courtesy-layer parity — server-side data access is not at issue here and is not what this clause claims)", + "an inactive app appearing as a switch target, or the switch group rendering with only one active app, is a FAIL", + "this item deliberately does NOT cover record hits, recents, RLS parity, or the open-affordance/idempotent-open matrix — those are search.console-global-search's clauses; a run must not double-score them here" + ], + "traps": ["hydration-race", "stale-console-bundle", "automation-input"], + "source": [ + "objectui packages/app-shell/src/chrome/CommandPalette.tsx:2-8 (the palette's declared scope: 'quick navigation across apps, objects, dashboards, pages, reports, and global actions'), :84-98 (flattened nav corpus + evaluateVisibility filter + searchable-object whitelist), :255-328 (the four per-type CommandGroups, onSelect → navigate(resolveHref…)), :331-355 (Switch app, active !== false, current marker), :357-372 (theme commands via setTheme), :374-385 (Open full search → /search), :391-402 (flattenNavigation leaves-only)", + "objectui packages/app-shell/src/context/CommandPaletteProvider.tsx (the shared open path — covered by search.console-global-search clause 1, cross-referenced not re-proven)", + "examples/app-showcase/src/ui/apps/index.ts:30-40,50-65,92-95,102-105 (the fixture: page/object/dashboard/report nav declarations, nested groups)", + "search.console-global-search (the sibling item owning the record-search half of the same palette: GET /api/v1/search hits, grouping #3371, recents, RLS parity, /search page — the boundary both items keep so neither double-covers)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, angle 1), authored as a SIBLING of search.console-global-search rather than an extension — writer's call per the register: that item's identity is the GET /api/v1/search record path (hits, grouping, RLS, recents, /search page) and already carries seven clauses, while the palette's other half — the client-side nav corpus (objects/dashboards/pages/reports groups from the active app's navigation), per-type Enter navigation, app switching, theme dispatch, and the evaluateVisibility gate — was wholly uncovered and is a different mechanism (app metadata + client filter, no server search). Grounded in CommandPalette.tsx; the gate-hidden side is blocked(fixture) on stock seeds because no showcase nav item declares a visibility expression and app metadata is not runtime-writable", "ref": "#sweep-2026-08-30" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index bed99cd015..4912ac8fb0 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -1253,6 +1253,113 @@ "history": [ { "revision": 1, "date": "2026-08-26", "change": "new item (#12438, E1 tier-1 claim / ADR-0126 §3 Regime O): 'views and dashboards are yours to edit directly' proven on PACKAGED artifacts through the designer — live edit, publish, end-user render, reset-overlay back to the shipped base — with a built-in dashboard leg (system_overview; the dashboard kind's allowOrgOverride:true overlay door verified in the registry at metadata-plugin.zod.ts:787 before asserting, per the register's instruction) and a same-surface Regime-C flow negative. Complements org-override-registry-gate (raw-PUT half) and view-authoring-live (which deliberately avoids packaged views)", "ref": "#12438" } ] + }, + { + "id": "studio-authoring.metadata-diagnostics-sweep", + "title": "The metadata governance sweep: GET /meta/diagnostics reports every STORED item failing spec validation with located errors, refuses outages and bad spellings loudly instead of answering '0 problems', and the Studio diagnostics page + directory tile badges paint the same numbers", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["admin (the governance surface is Studio's)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a console bundle matching the .objectui-sha pin (the DiagnosticsPage / DirectoryPage halves are objectui code)", + "the stock metadata corpus (any booted showcase — the sweep runs over whatever the store holds)" + ], + "knownGaps": [ + "THE POSITIVE ENTRY LEG IS FIXTURE-BLOCKED ON STOCK, BY CONSTRUCTION: every authoring door validates at save (the 422 gate studio-authoring.authoring-validation-not-persisted proves), so a stock boot's store holds NO invalid item and the sweep legitimately answers clean — entries:[], total:0. An invalid STORED row exists only via spec evolution (a row saved under an older spec the current schema rejects — the population POST /meta/_migrate-stored exists for), and sys_metadata is a storage table, not a data-plane object, so a runner cannot plant one through /api/v1/data either. The invalid-entry clause therefore scores blocked(fixture) until a stale-row fixture exists (a pre-seeded DB snapshot from an older spec revision is the honest recipe candidate); the CLEAN-store empty state is the live positive in the meantime, and the outage/spelling refusals are pinned at the unit layer", + "severity=warning ADDS NO ROWS ON TODAY'S PRODUCERS: the sweep's contract includes warning-only entries (protocol.ts getMetaDiagnostics: valid items with diagnostics.warnings), but computeMetadataDiagnostics emits errors only and the docstring reserves warnings 'for a future lint layer' — no read-decoration producer emits a warning at head. The severity clause asserts the MECHANISM (param accepted, error rows unchanged), not warning rows; a warning row appearing is a finding that the lint layer landed (revise this item), never noise", + "REPO OWNERSHIP (ADR-0054): the API half — route, sweep, stats, refusals — is objectstack's; the page markup, grouping, deep links, badges are objectui's (metadata-admin/DiagnosticsPage.tsx, DirectoryPage.tsx, useMetadata.ts). A locator or copy fix goes to objectui" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "GET /api/v1/meta/diagnostics on the live REST server and record the BARE body — { entries, total, scannedTypes, scannedItems, stats } with NO { success, data } envelope on this surface (rest-route-ledger.ts:146-148); ⛔ the route is REST-server-only, so a simulated dispatch is not an oracle here", + "stats parity probe: for two or three types (object, view, flow), GET /api/v1/meta/ and compare stats[].count against the list's item count and stats[].packages against the sorted distinct _packageId set of those items", + "package scoping: GET /api/v1/meta/diagnostics?package=com.example.showcase and record how stats narrows; then open Studio's metadata directory (/apps//metadata?package=com.example.showcase) and compare the tile counts against the scoped stats (DirectoryPage feeds its tiles from the package-scoped sweep)", + "severity mechanism: GET /api/v1/meta/diagnostics?severity=warning — capture that it answers 200 and that the error entries are unchanged (no warning producer exists at head; see knownGaps)", + "refusal probes: GET /api/v1/meta/diagnostics?type=viewes (an unrecognised spelling of a DECLARED type) and capture the 400; GET /api/v1/meta/diagnostics?severity=error&severity=warning (a repeated query param) and capture that refusal too", + "browser, clean store: open /apps//metadata/_diagnostics — screenshot it settled; on a clean corpus the DESIGNED empty state must render (title + the scanned-items/types counts), never a blank region; back on the directory page confirm the red diagnostics-link button is ABSENT (it renders only when total > 0)", + "outage clause: run the unit pin (packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts) and cite its output — driving a live store outage is not attempted", + "blocked leg (only when an invalid-stored-row fixture exists): re-GET the sweep — the planted row must appear as { type, name, diagnostics: { errors: [{ path, message }] } } with the LOCATED path, the page must group it under its type with a deep link to ..// (the item's editor), and the directory badge count must include it" + ], + "acceptance": [ + { + "clause": "the sweep contract holds on the live server: the bare body carries entries/total/scannedTypes/scannedItems/stats, total === entries.length, and scannedTypes covers exactly the registry types with a registered Zod schema (schema-less types are skipped as 'no opinion' — never counted valid, never failing the sweep)", + "oracle": "api", + "verify": "the recorded body parses against GetMetaDiagnosticsResponseSchema's shape (protocol.zod.ts:1461); total equals the entries length; scannedTypes is the registry-with-schema count, not the full registry count (getMetaDiagnostics filters on getMetadataTypeSchema)", + "evidence": "the /meta/diagnostics body" + }, + { + "clause": "stats tell the directory's truth: per sampled type, stats[type].count equals the type's own list length and stats[type].packages equals the sorted distinct _packageId set — the one-round-trip aggregate the Studio directory tiles render from", + "oracle": "api", + "verify": "side-by-side of stats[] against the GET /meta/ list for each sampled type (count, packages; locked counts rows whose _lock != 'none')", + "evidence": "the stats excerpt + the list reads" + }, + { + "clause": "?package= scoping narrows the whole sweep and the Studio directory tiles match it: with the sweep scoped to com.example.showcase, stats reflect only that package's items, and the rendered tile counts on the package-scoped directory equal the scoped stats — tile numbers match the scoped list pages, not the global corpus", + "oracle": "api", + "verify": "the ?package= body's stats vs the unscoped body; then the directory tiles (rendered only after the screenshot confirms the page settled) vs stats[].count — DirectoryPage drives them from useGlobalDiagnostics(client, 'warning', activePackage), so a mismatch is a real seam defect", + "evidence": "scoped + unscoped stats, and the tile screenshot annotated with the compared counts" + }, + { + "clause": "an unrecognised spelling of a declared type is REFUSED, never absorbed: ?type=viewes answers the 400 INVALID_REQUEST that names both accepted spellings — not 200 {\"scannedTypes\":1,\"stats\":{}}, which objectui's page paints as 'All clear' (#8924: the producer classified the caller's mistake; the sweep may not overrule a verdict into silence)", + "oracle": "api", + "verify": "the ?type=viewes response is 400 with the canonicalizeMetaRequestType refusal (the same one every sibling /meta door gives); the ?severity=error&severity=warning probe is refused too (refuseRepeatedQueryParams, #6877 — severity/type/package are all single-valued)", + "evidence": "the two refusal bodies" + }, + { + "clause": "severity=warning is accepted and widens only by contract: the response is 200, the error entries are byte-identical to the severity=error sweep, and no warning rows appear — because no producer emits warnings at head ('reserved for a future lint layer'); the clause records that posture so a future warning row is recognised as the lint layer landing, not a defect", + "oracle": "api", + "verify": "diff the two sweeps' entries; identical at head. A NEW warning-only entry appearing is not a FAIL — it is the trigger to revise this item (and the UI already wires it: warn-only rows get the amber badge, error rows dominate)", + "evidence": "the two bodies diffed" + }, + { + "clause": "the clean store renders the DESIGNED empty state, and absence is honest in both directions: the DiagnosticsPage shows its all-clear title with the scanned counts (never a blank region), and the directory's red diagnostics-link button does not render while total is 0", + "oracle": "screenshot", + "verify": "the settled /metadata/_diagnostics screenshot shows the Empty state with the scannedItems/scannedTypes numbers matching the API body; the directory header has no diagnostics button (DirectoryPage renders it only when diagSummary.total > 0)", + "evidence": "the two screenshots + the API body's counts" + }, + { + "clause": "an invalid STORED item surfaces with a located error and a working deep link: the sweep lists it as type+name+errors[{path,message}] (union failures named per-branch via zodIssuesToMetadataIssues — never one rootless 'Invalid input', #5598), the page groups it under its type, and the row's link lands on that item's editor — blocked(fixture) on stock (see knownGaps: the save gates make an invalid stored row unplantable in-run)", + "oracle": "api", + "verify": "with the stale-row fixture: the entry's diagnostics.errors carry the failing path; clicking the row navigates to ..// (the ResourceEditPage for the item); the DirectoryPage invalid badge for that type increments to match", + "evidence": "the sweep entry + the deep-link navigation screenshot" + }, + { + "clause": "a store outage is a loud 503, never '0 problems': when the metadata store read fails for a non-benign reason, getMetaDiagnostics rethrows the classified 503 instead of publishing an unreadable store as a clean sweep (#8855; ADR-0110 D3 — a miss and an outage are different facts)", + "oracle": "test", + "verify": "run packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts and cite its output — the pin drives the outage this item does not stage live", + "evidence": "the test run output" + } + ], + "negative": [ + "a 200 {\"scannedTypes\":1,\"stats\":{}} for a misspelled declared type is the #8924 silence regressed — the page would paint it 'All clear' — FAIL", + "an outage answered as a clean sweep (total 0 from a store nobody could read) is the #8855 / ADR-0110 D3 FAIL shape — a diagnostics endpoint whose job is reporting problems must not answer '0 problems' precisely when it cannot read anything", + "a blank diagnostics region on a clean store (instead of the designed empty state) is a FAIL of the page, not proof of health — and conversely, do NOT read the clean answer itself as 'the feature is missing': a store with no invalid rows is the healthy norm the save gates enforce (absence-inference)", + "consulting the dispatcher for this route is a recording error — the ledger marks GET /meta/diagnostics REST-server-only, so a simulated dispatch 404 says nothing about the live server (dispatcher-vs-hono-route)", + "SCOPE: per-item _diagnostics badges on list rows and the editor banner are the item-grain surfaces (ResourceListPage/ResourceEditPage) — this item owns only the cross-type sweep + directory/diagnostics pages; the save-time 422 gate is studio-authoring.authoring-validation-not-persisted — cite, don't duplicate" + ], + "traps": ["dispatcher-vs-hono-route", "hydration-race", "absence-inference", "stale-console-bundle"], + "automated": { + "kind": "test", + "ref": "packages/metadata-protocol/src/protocol.diagnostics-store-outage.test.ts — pins the outage clause ONLY (503 rethrown, never '0 problems'); the live sweep, scoping parity, refusals, and both browser surfaces still need the run" + }, + "source": [ + "packages/rest/src/rest-server.ts:4094-4138 (the route: registered BEFORE /meta/:type so 'diagnostics' is not captured as a type; severity defaults 'error'; ?type/?severity/?package each single-valued via refuseRepeatedQueryParams #6877; 501 NOT_IMPLEMENTED when the kernel's protocol lacks getMetaDiagnostics; answers res.json(result) BARE)", + "packages/rest/src/rest-route-ledger.ts:146-148 (REST-only route, bare body = GetMetaDiagnosticsResponseSchema, client meta.getDiagnostics, #12038)", + "packages/metadata-protocol/src/protocol.ts:5539-5732 (getMetaDiagnostics: registry-derived type set filtered to registered schemas; reuses the _diagnostics read decoration; stats { count, locked, packages } computed in the same sweep 'so the Studio directory page can render tile counts and a package filter in one round-trip'; 503 outage rethrown #8855 / ADR-0110 D3; 400 unrecognised-spelling rethrown #8924; warnings 'reserved for a future lint layer')", + "packages/metadata-protocol/src/metadata-diagnostics.ts (computeMetadataDiagnostics — errors only, undefined = no opinion for schema-less types; zodIssuesToMetadataIssues names union branches #5598 so Studio has a path to highlight)", + "packages/spec/src/api/protocol.zod.ts:1450-1470 (GetMetaDiagnosticsResponseSchema — entries[].diagnostics is the canonical MetadataValidationResultSchema the save path's 422 also speaks)", + "objectui packages/app-shell/src/views/metadata-admin/DiagnosticsPage.tsx (groups by type descending, per-row deep link ..//, severity tabs, the summary badge, the clean Empty state, the loadFailed banner) + useMetadata.ts:239-370 (useGlobalDiagnostics: strict error count vs warn-only count, counts/locked/packages from stats, older-server catch degrades to empty-not-fatal) + DirectoryPage.tsx:133-158,242-254 (package-scoped sweep drives tile badges; diagnostics link only when total > 0) + console/AppContent.tsx:882,982 (the metadata/_diagnostics route)", + "studio-authoring.authoring-validation-not-persisted (why stock stores are clean — the save-time gate; also the known spurious _diagnostics banner on the DESIGNER, which is that item's business, not this sweep's)" + ], + "history": [ + { "revision": 1, "date": "2026-08-30", "change": "new item (2026-08-30 sweep, angle 4): the governance sweep GET /meta/diagnostics + its two Studio surfaces had no coverage — nothing asserted the stats/tile parity seam, the #8924 spelling refusal (whose regression the page paints as 'All clear'), the #8855 outage honesty, or the clean-store empty state. Grounded against source with two corrections to the register hypothesis: the scoping query param is ?package= (not ?packageId= — that is the protocol-layer argument name), and severity=warning cannot 'add warning-only entries' on stock because no producer emits warnings at head (reserved for a future lint layer) — authored as a mechanism clause with the lint-layer tripwire recorded. The invalid-stored-row positive is blocked(fixture) by construction (save doors validate; sys_metadata is not a data-plane object), with the stale-row snapshot named as the honest fixture candidate", "ref": "#sweep-2026-08-30" } + ] } ] } diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index 7f0bee8c44..e43d5b23f6 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -15,23 +15,32 @@ "agent": { "items": [ "ai.agent-tool-skill-metadata-roundtrip", - "ai.open-edition-honest-degradation" + "ai.open-edition-honest-degradation", + "ai.console-ai-surface-gating" ] }, "api": { "items": [ - "api-backend.declarative-endpoint-execution" + "api-backend.declarative-endpoint-execution", + "records-forms.record-clone-contract", + "api-backend.api-methods-verb-gate", + "approvals.email-action-token-door" ] }, "app": { "items": [ "platform-core.boot-health", - "platform-core.nav-surfaces-render" + "platform-core.nav-surfaces-render", + "access-security.me-permissions-aggregation-parity", + "search.command-palette-navigation", + "platform-core.marketplace-install-local-lifecycle", + "platform-core.marketplace-console-honesty" ] }, "book": { "items": [ - "platform-core.docs-audience-gate" + "platform-core.docs-audience-gate", + "platform-core.docs-portal-render" ] }, "dashboard": { @@ -41,29 +50,36 @@ "dashboards.global-filters-rescope", "dashboards.chart-first-paint", "dashboards.empty-null-bucket-boundaries", - "studio-authoring.packaged-display-class-direct-edit" + "studio-authoring.packaged-display-class-direct-edit", + "search.command-palette-navigation" ] }, "dataset": { "items": [ - "dashboards.dataset-report-authoring" + "dashboards.dataset-report-authoring", + "api-backend.aggregate-contract-matrix" ] }, "datasource": { "items": [ "integration-system.external-datasource-federated-read", "integration-system.external-schema-introspection", - "integration-system.external-schema-drift-gate" + "integration-system.external-schema-drift-gate", + "records-forms.field-unique-enforcement", + "integration-system.external-schema-browser-ui", + "cli.dev-automigrate-policy" ] }, "doc": { "items": [ - "platform-core.docs-audience-gate" + "platform-core.docs-audience-gate", + "platform-core.docs-portal-render" ] }, "email_template": { "items": [ - "integration-system.email-template-render" + "integration-system.email-template-render", + "identity-auth.email-verification-loop" ] }, "field": { @@ -72,7 +88,15 @@ "records-forms.conditional-rules-header", "records-forms.cascading-options", "records-forms.lookup-picker-create-new", - "attachments-storage.field-accept-maxsize-server-enforced" + "attachments-storage.field-accept-maxsize-server-enforced", + "records-forms.field-group-visible-when", + "records-forms.field-unique-enforcement", + "records-forms.delete-behavior-matrix", + "records-forms.field-history-tracking", + "api-backend.aggregate-contract-matrix", + "api-backend.formula-stdlib-matrix", + "approvals.status-mirror-field", + "cli.dev-automigrate-policy" ] }, "flow": { @@ -90,13 +114,17 @@ "automation.setup-packaged-automation-board", "access-security.activation-write-operator-gate", "access-security.packaged-flow-write-door-parity", - "studio-authoring.packaged-automation-studio-lock" + "studio-authoring.packaged-automation-studio-lock", + "approvals.email-action-token-door", + "approvals.approver-resolution-matrix", + "approvals.status-mirror-field" ] }, "hook": { "items": [ "records-forms.object-hook-lifecycle", - "cli.hook-body-extraction-gates" + "cli.hook-body-extraction-gates", + "access-security.record-view-read-audit" ] }, "job": { @@ -108,12 +136,14 @@ "items": [ "platform-core.manifest-install-contract", "cli.plugin-manifest-build-contract", - "api-backend.package-rest-lifecycle" + "api-backend.package-rest-lifecycle", + "platform-core.marketplace-install-local-lifecycle" ] }, "mapping": { "items": [ - "records-forms.named-import-mapping" + "records-forms.named-import-mapping", + "records-forms.import-transform-matrix" ] }, "object": { @@ -121,13 +151,26 @@ "records-forms.crud-roundtrip", "platform-core.metadata-authoring-roundtrip", "studio-authoring.object-designer-roundtrip", - "platform-core.packaged-object-extend-only" + "platform-core.packaged-object-extend-only", + "records-forms.field-group-visible-when", + "records-forms.field-unique-enforcement", + "records-forms.delete-behavior-matrix", + "records-forms.record-clone-contract", + "records-forms.field-history-tracking", + "api-backend.aggregate-contract-matrix", + "api-backend.formula-stdlib-matrix", + "api-backend.api-methods-verb-gate", + "integration-system.external-schema-browser-ui", + "search.command-palette-navigation", + "platform-core.lifecycle-retention-sweep" ] }, "page": { "items": [ "studio-authoring.record-page-roundtrip", - "platform-core.nav-surfaces-render" + "platform-core.nav-surfaces-render", + "search.command-palette-navigation", + "platform-core.marketplace-console-honesty" ] }, "permission": { @@ -142,7 +185,10 @@ "access-security.sharing-rule-authoring-ui", "access-security.owd-save-gate", "access-security.share-link-capability-tokens", - "access-security.packaged-permission-set-lifecycle" + "access-security.packaged-permission-set-lifecycle", + "access-security.platform-owner-email-anchor", + "access-security.me-permissions-aggregation-parity", + "api-backend.api-methods-verb-gate" ] }, "position": { @@ -150,7 +196,8 @@ "access-security.scope-depth-asymmetry", "approvals.per-group-signoff", "approvals.dynamic-approver-routing", - "identity-auth.teams-bu-membership" + "identity-auth.teams-bu-membership", + "approvals.approver-resolution-matrix" ] }, "qa": { @@ -160,19 +207,24 @@ }, "query": { "items": [ - "api-backend.query-contract-matrix" + "api-backend.query-contract-matrix", + "api-backend.aggregate-contract-matrix" ] }, "report": { "items": [ "dashboards.dataset-report-authoring", "dashboards.drill-through-range", - "dashboards.saved-report-ownership" + "dashboards.saved-report-ownership", + "dashboards.report-schedule-dispatch-delivery", + "search.command-palette-navigation" ] }, "seed": { "items": [ - "platform-core.seed-integrity" + "platform-core.seed-integrity", + "platform-core.marketplace-install-local-lifecycle", + "platform-core.seed-mode-matrix" ] }, "skill": { @@ -208,7 +260,8 @@ "studio-authoring.view-authoring-live", "records-forms.gantt-interactions", "records-forms.saved-view-management", - "studio-authoring.packaged-display-class-direct-edit" + "studio-authoring.packaged-display-class-direct-edit", + "records-forms.field-history-tracking" ] }, "webhook": { @@ -218,8 +271,10 @@ }, "capability": { "items": [ - "access-security.capability-declaration-lifecycle" + "access-security.capability-declaration-lifecycle", + "ai.console-ai-surface-gating", + "platform-core.marketplace-install-local-lifecycle" ] } } -} \ No newline at end of file +}