diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index 2891bdf8f7..ad322c00c4 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -80,7 +80,7 @@ Ledger 182 → 190 items; `coverage.json` 28 mapped / 2 waived → **30 mapped / | # | drift | evidence | captured in | |---|---|---|---| -| E1 | **Stale defect note in `packages/spec/liveness/doc.json`.** Its `_note` records "DocSchema declares no `tags`, yet the book-side `include: { tag }` rule and the REST corpus both expect one — the tag rule can currently never match". That defect is **fixed**: `DocSchema` now declares `tags` (`packages/spec/src/system/doc.zod.ts:126`, with the history spelled out in the surrounding comment). The ledger note now describes a bug that no longer exists, which is the same failure class this sweep is correcting in `SWEEP.md`. Outside this card's file surface (`docs/qa/platform-checklist/**`), so it is reported, not edited. | `packages/spec/liveness/doc.json` `_note` vs `packages/spec/src/system/doc.zod.ts:111-127` | — (liveness ledger prose, not a checklist item) | +| E1 | **Stale defect note in `packages/spec/liveness/doc.json`.** Its `_note` records "DocSchema declares no `tags`, yet the book-side `include: { tag }` rule and the REST corpus both expect one — the tag rule can currently never match". That defect is **fixed**: `DocSchema` now declares `tags` (`packages/spec/src/system/doc.zod.ts`, with the history spelled out in the surrounding comment). The ledger note now describes a bug that no longer exists, which is the same failure class this sweep is correcting in `SWEEP.md`. Outside this card's file surface (`docs/qa/platform-checklist/**`), so it is reported, not edited. | `packages/spec/liveness/doc.json` `_note` vs `packages/spec/src/system/doc.zod.ts` | — (liveness ledger prose, not a checklist item) | ### 5b. Checked and CLEAN (recorded so the next sweep does not re-derive it) @@ -89,20 +89,20 @@ Ledger 182 → 190 items; `coverage.json` 28 mapped / 2 waived → **30 mapped / quick-start's `npx os dev --ui` and inferred that a newcomer running `npm run dev` would land on a server with no console. **Source-checking the chain refuted it**, and the refutation is recorded here because the inference is an easy one to make twice: - - `packages/cli/src/commands/serve.ts:221` — `ui: Flags.boolean({ …, default: true, + - `packages/cli/src/commands/serve.ts` — `ui: Flags.boolean({ …, default: true, allowNo: true })`. The console is **default-ON** at `serve`; `--no-ui` is the off switch. - - `packages/cli/src/commands/dev.ts:370` — `...(flags.ui ? ['--ui'] : [])`. `dev` only - ever **adds** `--ui`; it never forwards `--no-ui`. With `dev.ts:69` declaring `ui` + - `packages/cli/src/commands/dev.ts` — `...(flags.ui ? ['--ui'] : [])`. `dev` only + ever **adds** `--ui`; it never forwards `--no-ui`. With `dev.ts` declaring `ui` with no `default`, an unflagged `dev` spawns `serve` with no ui flag at all, so serve's own default takes over — on. - - `content/docs/deployment/cli.mdx:139` says it outright: "`--ui` | Force Console UI on - (**already on by default in dev**)", and `:196` documents `--ui / --no-ui … (default + - `content/docs/deployment/cli.mdx` says it outright: "`--ui` | Force Console UI on + (**already on by default in dev**)", and `` documents `--ui / --no-ui … (default on)`. So `--ui` on `dev` is a **no-op forwarder**, there is no divergence between the template script and quick-start, and the newcomer is not stranded. What made the wrong - reading tempting is `dev.ts:69`'s own flag description ("Enable the bundled Console + reading tempting is `dev.ts`'s own flag description ("Enable the bundled Console portal at /_console/"), which reads like the mechanism when it is only a forwarder — not worth a change on its own, but worth knowing before inferring from it. `cli.scaffold-console-first-paint` clause 3 now asserts the two invocations **agree**, @@ -183,13 +183,13 @@ governance hole. | surface | evidence | the deadness, precisely | |---|---|---| -| `PluginSecurityScanner` (`packages/core/src/security/security-scanner.ts:43`) | zero constructors outside `packages/core/examples/`; not in plugin-loader, service-package, rest, or any CLI path | Exported dead code on the PUBLIC barrel (`packages/core/src/index.ts:28` re-exports `./security/index.js`). 3 of 5 scan methods are empty stubs; `scanDependencies` has a real loop whose only data source (`addVulnerability`, `:309`) has zero callers; `updateVulnerabilityDatabase` (`:344`) is a log-only no-op. | -| `KernelSecurityScanResult` / `KernelSecurityVulnerability` / `PluginSecurityManifest.scanResults` (`packages/spec/src/kernel/plugin-security-advanced.zod.ts:385,476,625`) | no `.parse`/`.safeParse` site anywhere; only consumer is the dead scanner (type-only import) | 22 rows published to `packages/spec/authorable-surface/kernel.json:286-310` with zero authors and zero parsers. The whole `plugin-security-advanced` module has no runtime consumer. | -| `PluginQualityMetrics.securityScan` (`packages/spec/src/kernel/plugin-registry.zod.ts:73-83`) | spec self-test only | Nothing reads or writes it at runtime. | -| Marketplace/incident scan vocab (`marketplace.zod.ts:348` 'scanning' status, `marketplace-admin.zod.ts:42,193`, `incident-response.zod.ts:39` 'malware') | declared-only enum members, no producer in this repo | Cloud/EE surface. Same shape as the `'failed'`/`'expired'` upload statuses #7667 had to close: declared, published, no writer. | -| MetadataPlugin FS scan + `metadata-fs` boot scan (`packages/metadata/src/plugin.ts:257,270` — `watch ?? false`; `packages/runtime/src/standalone-stack.ts:698-702` hard-off; `metadata-fs` unwired from any `os dev`/`os serve` lane) | unit-pinned in-package only | No reachable fixture from any shipped boot; if a future lane wires `metadata-fs`, the boot-scan/watcher dot-entry divergence is the risk to test first. | +| `PluginSecurityScanner` (`packages/core/src/security/security-scanner.ts`) | zero constructors outside `packages/core/examples/`; not in plugin-loader, service-package, rest, or any CLI path | Exported dead code on the PUBLIC barrel (`packages/core/src/index.ts` re-exports `./security/index.js`). 3 of 5 scan methods are empty stubs; `scanDependencies` has a real loop whose only data source (`addVulnerability`, ``) has zero callers; `updateVulnerabilityDatabase` (``) is a log-only no-op. | +| `KernelSecurityScanResult` / `KernelSecurityVulnerability` / `PluginSecurityManifest.scanResults` (`packages/spec/src/kernel/plugin-security-advanced.zod.ts,476,625`) | no `.parse`/`.safeParse` site anywhere; only consumer is the dead scanner (type-only import) | 22 rows published to `packages/spec/authorable-surface/kernel.json` with zero authors and zero parsers. The whole `plugin-security-advanced` module has no runtime consumer. | +| `PluginQualityMetrics.securityScan` (`packages/spec/src/kernel/plugin-registry.zod.ts`) | spec self-test only | Nothing reads or writes it at runtime. | +| Marketplace/incident scan vocab (`marketplace.zod.ts` 'scanning' status, `marketplace-admin.zod.ts,193`, `incident-response.zod.ts` 'malware') | declared-only enum members, no producer in this repo | Cloud/EE surface. Same shape as the `'failed'`/`'expired'` upload statuses #7667 had to close: declared, published, no writer. | +| MetadataPlugin FS scan + `metadata-fs` boot scan (`packages/metadata/src/plugin.ts,270` — `watch ?? false`; `packages/runtime/src/standalone-stack.ts` hard-off; `metadata-fs` unwired from any `os dev`/`os serve` lane) | unit-pinned in-package only | No reachable fixture from any shipped boot; if a future lane wires `metadata-fs`, the boot-scan/watcher dot-entry divergence is the risk to test first. | -Compounding the first row: `packages/core/PHASE2_IMPLEMENTATION.md:266-311` advertises +Compounding the first row: `packages/core/PHASE2_IMPLEMENTATION.md` advertises the scanner as a working feature, tells readers to import from `@objectstack/core/security` (a subpath `packages/core/package.json` does not export), and its sample fields (`scanResult.passed`/`.score`/`.summary.critical`) do not exist on the actual schema — @@ -199,38 +199,38 @@ to the authorable-surface rows. ### 7b. Docs drift (PD#10 class — file as docs fixes, not checklist items) -- **Phantom `contentProcessing` virus scanning** — `content/docs/protocol/objectql/types.mdx:1067-1069` - and `:1086` promise "thumbnail generation, virus scanning … under `contentProcessing`"; - `content/docs/data-modeling/validation-rules.mdx:302` redirects file-storage virus +- **Phantom `contentProcessing` virus scanning** — `content/docs/protocol/objectql/types.mdx` + and `` promise "thumbnail generation, virus scanning … under `contentProcessing`"; + `content/docs/data-modeling/validation-rules.mdx` redirects file-storage virus scanning to a connector setting. `contentProcessing` exists in exactly those doc lines — no schema key, no code, and no content inspection of any kind exists on the upload path - (mimeType is trusted verbatim from the client body, `storage-routes.ts:241-243`). Both + (mimeType is trusted verbatim from the client body, `storage-routes.ts`). Both docs should say plainly the platform performs no upload-time content inspection; the new `attachments-storage.field-accept-maxsize-server-enforced` item records the same boundary on the QA side. - **Dead remediation prescription in a live command — FIXED in #10882 (closing #10680), 2026-08-21.** `doctor.ts` used to print "Run `objectstack codemod v2-to-v3` to auto-fix"; no `codemod` command has ever been registered. At head the print site - (`doctor.ts:2283-2288`) prescribes **no** command — it says no automated codemod ships + (`doctor.ts`) prescribes **no** command — it says no automated codemod ships with the CLI and routes the operator to the `→ replacement` already computed per finding, and `packages/cli/src/commands/doctor-deprecation-hint-commands.test.ts` pins the class so a future phantom prescription fails there rather than shipping. **A repoint at `os migrate meta` was considered and REFUSED**: its subject is an authored stack config, its header declines the AST rewrite as "unsafe and lossy", both its writes are `--out` JSON snapshots, and three of the eight `DEPRECATED_PATTERNS` are not metadata at all — - reasoning at `doctor.ts:2258-2282`, refusal pinned at that test's `:199`. An earlier + reasoning at `doctor.ts`, refusal pinned at that test's ``. An earlier wording of this row prescribed that repoint; it is retracted, and it is what seeded the same suggestion in #10680 for #10882 to argue down — do not re-derive it. - `content/docs/protocol/backward-compatibility.mdx:134` still records the codemod as "not + `content/docs/protocol/backward-compatibility.mdx` still records the codemod as "not yet available", but at head that is **consistent** with the tool rather than the contradiction this row cited it for: correct as written, deliberately not edited (#11420, and again here). The interim probe this row pointed at (`cli.doctor-deprecation-scan`'s expected-fail clause) is refreshed to a positive assertion in #11638. -- **`metadata-service.mdx:188`** presents `eager` bootstrap as "Scans filesystem … at +- **`metadata-service.mdx`** presents `eager` bootstrap as "Scans filesystem … at boot (default)"; no shipped boot path scans (`watch` defaults false, `os dev` disables it explicitly). Stop advertising the scan as default behavior. -- **`admin-routes.ts:518` comment** claims a Studio "sync objects" consumer for +- **`admin-routes.ts` comment** claims a Studio "sync objects" consumer for `/remote-tables`; no such consumer exists in objectui/app-shell — the live callers are the two `os datasource` commands. Cleanup comment fix. @@ -238,18 +238,18 @@ to the authorable-surface rows. | # | defect | evidence | captured in | sensitivity | |---|---|---|---|---| -| D9 | `sys_two_factor.verified` declares `defaultValue: true` while better-auth enrols `verified: false` and `AUTH_TWO_FACTOR_SCHEMA` does not map `verified` — if better-auth omits the column on insert, the ObjectQL default marks an unverified enrolment active | `sys-two-factor.object.ts:166-170`; `auth-schema-config.ts:366-374` | identity-auth.two-factor-verify-to-activate (observe-and-flag probe) | **auth-integrity — do not file publicly without maintainer** | -| D10 | `sys_user.generate_backup_codes` has no `resultDialog` — on the only navigable surface the user regenerates codes they are never shown; permanent-lockout path | `sys-user.object.ts:435-452` | identity-auth.two-factor-backup-codes (observe-and-flag) | UX-integrity/auth — maintainer call | -| D11 | `POST /api/v1/auth/two-factor/get-totp-uri` live re-reveal vs the reveal dialog's "shown only once" promise; endpoint absent from SDK ledger rows and targeted by no action | `auth-route-ledger.ts:374` vs `sys-two-factor.object.ts:76` | identity-auth.two-factor-enrollment-reveal (probe) | auth — maintainer call | -| D12 | `os doctor` false-PASS: `findMissingTests`/`findDeprecatedUsages` scan only `/packages/spec/src`, so in any user app doctor prints "✓ Test coverage"/"✓ Deprecations" about a tree it never examined | `doctor.ts:1141-1143,1159-1161` → `✓` at `:1939,:1951` | cli.doctor-health-report (expected-fail probe) | correctness — safe to file | -| D13 | `FileConstraintError` declares `code: 'ERR_FILE_CONSTRAINT'` but no `status`, and rest's `classifyDataError` has no branch for it — the server-side accept/maxSize refusal exits `/api/v1/data` as a sanitized **500 INTERNAL_ERROR** with the field-naming prose withheld from the body (the sibling `FileFieldBulkWriteError` docblock names `status: 400` as exactly what prevents this; same class as #7525) | `file-reference-lifecycle.ts:168-173,181-191`; `packages/rest` error-response classification | attachments-storage.field-accept-maxsize-server-enforced (wire-status recorded per run; a measured 500 is extracted as a finding, not scored as an enforcement fail) | correctness/wire-contract — safe to file | -| D14 | `MigrationRecoveryPlugin` is composed by NO boot path — `serve.ts` auto-registers `PlatformObjectsPlugin` but never the recovery plugin; standalone-stack, default-host, the showcase config, and the migrate CLI boot all omit it; only its unit test instantiates it. Interrupted-migration detection therefore never runs on any shipped boot, while `sys-migration-journal.object.ts:56-58` argues recovery must need "zero host wiring" | `packages/runtime/src/index.ts:58` (exported); `serve.ts:2073-2098` (what IS auto-registered) | platform-core.interrupted-migration-boot-report (fixtures an explicit registration; knownGap names the composition hole) | correctness/composition — safe to file | -| D15 | `extract-hook-body.ts:14-18`'s header promises "the build fails… no silent fallback" on a forbidden pattern, but the DEFAULT `os build` catches every extraction error and silently falls back to the .mjs bundle (`lower-callables.ts:63-78`), printing the warnings nowhere; only `--strict-body` (`compile.ts:126-149`) produces the worded refusals with exit 1. `hook-bodies.mdx:256` documents the warn-and-bundle default, so code comment and docs disagree with each other | `extract-hook-body.ts:14-18` vs `lower-callables.ts:63-78`, `compile.ts:126-149` | cli.hook-body-extraction-gates (default-path silent-fallback encoded as expected-fail contradiction clause) | correctness — safe to file | +| D9 | `sys_two_factor.verified` declares `defaultValue: true` while better-auth enrols `verified: false` and `AUTH_TWO_FACTOR_SCHEMA` does not map `verified` — if better-auth omits the column on insert, the ObjectQL default marks an unverified enrolment active | `sys-two-factor.object.ts`; `auth-schema-config.ts` | identity-auth.two-factor-verify-to-activate (observe-and-flag probe) | **auth-integrity — do not file publicly without maintainer** | +| D10 | `sys_user.generate_backup_codes` has no `resultDialog` — on the only navigable surface the user regenerates codes they are never shown; permanent-lockout path | `sys-user.object.ts` | identity-auth.two-factor-backup-codes (observe-and-flag) | UX-integrity/auth — maintainer call | +| D11 | `POST /api/v1/auth/two-factor/get-totp-uri` live re-reveal vs the reveal dialog's "shown only once" promise; endpoint absent from SDK ledger rows and targeted by no action | `auth-route-ledger.ts` vs `sys-two-factor.object.ts` | identity-auth.two-factor-enrollment-reveal (probe) | auth — maintainer call | +| D12 | `os doctor` false-PASS: `findMissingTests`/`findDeprecatedUsages` scan only `/packages/spec/src`, so in any user app doctor prints "✓ Test coverage"/"✓ Deprecations" about a tree it never examined | `doctor.ts,1159-1161` → `✓` at `` | cli.doctor-health-report (expected-fail probe) | correctness — safe to file | +| D13 | `FileConstraintError` declares `code: 'ERR_FILE_CONSTRAINT'` but no `status`, and rest's `classifyDataError` has no branch for it — the server-side accept/maxSize refusal exits `/api/v1/data` as a sanitized **500 INTERNAL_ERROR** with the field-naming prose withheld from the body (the sibling `FileFieldBulkWriteError` docblock names `status: 400` as exactly what prevents this; same class as #7525) | `file-reference-lifecycle.ts,181-191`; `packages/rest` error-response classification | attachments-storage.field-accept-maxsize-server-enforced (wire-status recorded per run; a measured 500 is extracted as a finding, not scored as an enforcement fail) | correctness/wire-contract — safe to file | +| D14 | `MigrationRecoveryPlugin` is composed by NO boot path — `serve.ts` auto-registers `PlatformObjectsPlugin` but never the recovery plugin; standalone-stack, default-host, the showcase config, and the migrate CLI boot all omit it; only its unit test instantiates it. Interrupted-migration detection therefore never runs on any shipped boot, while `sys-migration-journal.object.ts` argues recovery must need "zero host wiring" | `packages/runtime/src/index.ts` (exported); `serve.ts` (what IS auto-registered) | platform-core.interrupted-migration-boot-report (fixtures an explicit registration; knownGap names the composition hole) | correctness/composition — safe to file | +| D15 | `extract-hook-body.ts`'s header promises "the build fails… no silent fallback" on a forbidden pattern, but the DEFAULT `os build` catches every extraction error and silently falls back to the.mjs bundle (`lower-callables.ts`), printing the warnings nowhere; only `--strict-body` (`compile.ts`) produces the worded refusals with exit 1. `hook-bodies.mdx` documents the warn-and-bundle default, so code comment and docs disagree with each other | `extract-hook-body.ts` vs `lower-callables.ts`, `compile.ts` | cli.hook-body-extraction-gates (default-path silent-fallback encoded as expected-fail contradiction clause) | correctness — safe to file | Two design notes captured inside items rather than as defect rows: `sys_user.mfa_required_at` is stamped lazily and never cleared anywhere in source, so post-disable re-gating branches on a pre-existing stamp (identity-auth.two-factor-disable-lifecycle, design-note clause); and -`datasource.checkOnBoot` (spec `datasource.zod.ts:313`, default true, liveness-ledgered live) +`datasource.checkOnBoot` (spec `datasource.zod.ts`, default true, liveness-ledgered live) is read by NO runtime code — the drift scan always runs — a declared≠enforced ADR-0049 shape encoded as a finding clause in integration-system.external-schema-drift-gate and a liveness ledger correction candidate. @@ -259,7 +259,7 @@ ledger correction candidate. `coverage.json`'s kind universe derives from `packages/spec/liveness/*.json` — which has no `kernel`, `plugin`, `marketplace`, or `incident` kind. The entire `packages/spec/src/kernel/**` and `packages/spec/src/cloud/**` surface can grow, publish -to `content/docs/references/` (e.g. `references/kernel/plugin-security-advanced.mdx:17` +to `content/docs/references/` (e.g. `references/kernel/plugin-security-advanced.mdx` ships "Security scanning and verification" as a documented capability, auto-generated and banner-marked but backed by nothing), and never register as an unmapped kind. Decide: either those spec families join the liveness-governed set, or the ratchet's blind spot is @@ -268,12 +268,12 @@ recorded as accepted scope. Until then, only a sweep like this one can catch it. ### 7e. Checked and CLEAN (so the next sweep does not re-derive) - qrcode field type: scanning keys (`barcodeFormat`/`qrErrorCorrection`/`displayValue`/`allowScanning`) - pruned 2026-06, correctly dead (`field.zod.ts:1113-1119`); rendering covered by - `records-forms.field-type-matrix`; residue `suggestions.zod.ts:168` is a live + pruned 2026-06, correctly dead (`field.zod.ts`); rendering covered by + `records-forms.field-type-matrix`; residue `suggestions.zod.ts` is a live author-time typo alias (`barcode`→`qrcode`), not behavior. - No content sniffing on upload: not a capability — boundary recorded in the new attachments item, not a gap. -- `knowledge.mdx:38-39` PDF/scan extraction: an explicit protocol non-goal, no promise. +- `knowledge.mdx` PDF/scan extraction: an explicit protocol non-goal, no promise. - `packages/verify` conformance `scan`: internal proof-attribution bookkeeping. - 2FA challenge gate + lockout: covered (`identity-auth.auth-method-matrix` + `two-factor-lockout.dogfood.test.ts`); endpoint existence pinned by @@ -298,40 +298,40 @@ run records actual behavior instead of ticking green. | # | defect | evidence | captured in | sensitivity | |---|---|---|---|---| -| D16 | **Setup packaged-automation page is unreachable — no nav entry.** The page + registry ref `automation:packaged` shipped in objectui (`app-shell/src/views/setup/PackagedAutomationPage.tsx`, `services/builtinComponents.tsx:64-69`), but `packages/platform-objects/src/apps/setup-nav.contributions.ts` names no such item (only `developer:packages` at :51); reachable only by typed URL `/apps/setup/component/automation/packaged`. Epic L5 (#6301) and L6-UI (#6412) are CLOSED, so this is a dropped half, not pending work. Card **A1 is expected to fail**; `build-without-code.mdx:37` ships the promise publicly (docs ahead of surface — the sequencing ADR §8.5 asked to avoid). | objectstack `setup-nav.contributions.ts:32-171` vs objectui `PackagedAutomationPage.navContribution.test.tsx` (pins only the objectui half) | automation.setup-packaged-automation-board (expected-fail nav clause — a typed-URL pass must not tick it) | UX/release — **FILED as #12457** (2026-08-26); testers warned on #12438 | -| D17 | **`PUT`/`DELETE /api/v1/automation/:name` bypass the packaged lock** — `manage_metadata` alone re-registers/unregisters a packaged flow's live definition; `registerFlow` has zero `_lock`/provenance check, while `/meta/flow` refuses the same write. ADR-0126 §2 "refused loudly at the write door" is unimplemented at this door. | `packages/runtime/src/domains/automation.ts:1826-1865`; `packages/services/service-automation/src/engine.ts:2625-2730` | access-security.packaged-flow-write-door-parity (expected-fail parity clauses) | integrity — admin-gated, not an escalation; safe to file | -| D18 | **The flow clone is engine-registry-only** — no `sys_metadata` write on the clone path, `_packageId`/`_provenance` stripped, no post-clone navigation; Studio's Automations rail lists package-scoped metadata, so a package-less engine-only clone matches no package. "The clone is an ordinary flow, yours to edit in Studio" (ADR §1.3/§7.1, `build-without-code.mdx:37`) is unproven; restart survival unknown. | `domains/automation.ts:1414-1436` (registerFlow only); `flow-clone.ts:225`; objectui `StudioDesignSurface.tsx:3286`; `PackagedAutomationPage.tsx:302-313` | automation.packaged-flow-clone-contract (honest restart+Studio clause, expected-fail) | correctness — safe to file | -| D19 | **The subflow refusal's own remedy is dead** — "Disable the calling flow(s) first" is what the 409 prescribes, but `packagedSubflowCallers` scans the registered flow map with **no activation check**, so an already-DISABLED caller still guards its callee; the prescribed sequence can never complete. No test covers the sequence. | `engine.ts:2932-2947` (no activation consult) vs `:2992-2996` (the prescription); ADR §7.3 | automation.packaged-flow-subflow-disable-refusal (expected-fail remedy-sequence clause) | correctness — safe to file | -| D20 | **Extension-field collision silently OVERRIDES the shipped base field** — `mergeObjectDefinitions` spreads `extension.fields` over `base.fields`, and the authoring schema documents "Fields to add/override" with priority "wins on conflict". No generic collision gate exists (`managed-extension-fields` covers better-auth sys objects only; ADR §3 adopts it as *prior art*, not a live gate). May be by-design — but then integrations.mdx's "never by reshaping what shipped" overstates. | `packages/objectql/src/registry.ts:146`; `packages/spec/src/data/object.zod.ts:2979,2996-2997` | platform-core.packaged-object-extend-only (knownGap, do-not-file-as-FAIL rule) | integrity/design — needs a ruling | -| D21 | **Non-durable toggle disclosure is a server log line only** — with no activation ledger attached, `toggleFlow` warns "IN PROCESS ONLY … will NOT survive a restart", but the response body (`{name, enabled}`) and every UI surface carry nothing; card 已知边界 3's asymmetry has no user-facing channel and no docs sentence anywhere. | `engine.ts:3018-3028`; `domains/automation.ts:1321-1322` | — (not an item; needs a maintainer call on the channel: response field vs UI copy vs docs) | UX-integrity — maintainer call | -| D22 | **`POST /automation/:name/clone` is unledgered** — live route absent from `route-ledger.ts` and from the JS client; `api-backend.route-ledger-live-parity` runs ledger→live only, so an unledgered mount is invisible to it. Suggest a ledger row now; consider a reverse-parity (live→ledger) item as a standing gate. | `domains/automation.ts:1340` vs `route-ledger.ts:316-336` | — (not an item) | low — internal discipline | +| D16 | **Setup packaged-automation page is unreachable — no nav entry.** The page + registry ref `automation:packaged` shipped in objectui (`app-shell/src/views/setup/PackagedAutomationPage.tsx`, `services/builtinComponents.tsx`), but `packages/platform-objects/src/apps/setup-nav.contributions.ts` names no such item (only `developer:packages`); reachable only by typed URL `/apps/setup/component/automation/packaged`. Epic L5 (#6301) and L6-UI (#6412) are CLOSED, so this is a dropped half, not pending work. Card **A1 is expected to fail**; `build-without-code.mdx` ships the promise publicly (docs ahead of surface — the sequencing ADR §8.5 asked to avoid). | objectstack `setup-nav.contributions.ts` vs objectui `PackagedAutomationPage.navContribution.test.tsx` (pins only the objectui half) | automation.setup-packaged-automation-board (expected-fail nav clause — a typed-URL pass must not tick it) | UX/release — **FILED as #12457** (2026-08-26); testers warned on #12438 | +| D17 | **`PUT`/`DELETE /api/v1/automation/:name` bypass the packaged lock** — `manage_metadata` alone re-registers/unregisters a packaged flow's live definition; `registerFlow` has zero `_lock`/provenance check, while `/meta/flow` refuses the same write. ADR-0126 §2 "refused loudly at the write door" is unimplemented at this door. | `packages/runtime/src/domains/automation.ts`; `packages/services/service-automation/src/engine.ts` | access-security.packaged-flow-write-door-parity (expected-fail parity clauses) | integrity — admin-gated, not an escalation; safe to file | +| D18 | **The flow clone is engine-registry-only** — no `sys_metadata` write on the clone path, `_packageId`/`_provenance` stripped, no post-clone navigation; Studio's Automations rail lists package-scoped metadata, so a package-less engine-only clone matches no package. "The clone is an ordinary flow, yours to edit in Studio" (ADR §1.3/§7.1, `build-without-code.mdx`) is unproven; restart survival unknown. | `domains/automation.ts` (registerFlow only); `flow-clone.ts`; objectui `StudioDesignSurface.tsx`; `PackagedAutomationPage.tsx` | automation.packaged-flow-clone-contract (honest restart+Studio clause, expected-fail) | correctness — safe to file | +| D19 | **The subflow refusal's own remedy is dead** — "Disable the calling flow(s) first" is what the 409 prescribes, but `packagedSubflowCallers` scans the registered flow map with **no activation check**, so an already-DISABLED caller still guards its callee; the prescribed sequence can never complete. No test covers the sequence. | `engine.ts` (no activation consult) vs `` (the prescription); ADR §7.3 | automation.packaged-flow-subflow-disable-refusal (expected-fail remedy-sequence clause) | correctness — safe to file | +| D20 | **Extension-field collision silently OVERRIDES the shipped base field** — `mergeObjectDefinitions` spreads `extension.fields` over `base.fields`, and the authoring schema documents "Fields to add/override" with priority "wins on conflict". No generic collision gate exists (`managed-extension-fields` covers better-auth sys objects only; ADR §3 adopts it as *prior art*, not a live gate). May be by-design — but then integrations.mdx's "never by reshaping what shipped" overstates. | `packages/objectql/src/registry.ts`; `packages/spec/src/data/object.zod.ts,2996-2997` | platform-core.packaged-object-extend-only (knownGap, do-not-file-as-FAIL rule) | integrity/design — needs a ruling | +| D21 | **Non-durable toggle disclosure is a server log line only** — with no activation ledger attached, `toggleFlow` warns "IN PROCESS ONLY … will NOT survive a restart", but the response body (`{name, enabled}`) and every UI surface carry nothing; card 已知边界 3's asymmetry has no user-facing channel and no docs sentence anywhere. | `engine.ts`; `domains/automation.ts` | — (not an item; needs a maintainer call on the channel: response field vs UI copy vs docs) | UX-integrity — maintainer call | +| D22 | **`POST /automation/:name/clone` is unledgered** — live route absent from `route-ledger.ts` and from the JS client; `api-backend.route-ledger-live-parity` runs ledger→live only, so an unledgered mount is invisible to it. Suggest a ledger row now; consider a reverse-parity (live→ledger) item as a standing gate. | `domains/automation.ts` vs `route-ledger.ts` | — (not an item) | low — internal discipline | Two objectui-side polish rows captured inside `automation.setup-packaged-automation-board` rather than as defect rows: `actionErrorDetail` drops `details[]`, so field-level -prescriptions on validation refusals never reach the operator (`packages/core/src/actions/actionErrorDetail.ts:27-35` +prescriptions on validation refusals never reach the operator (`packages/core/src/actions/actionErrorDetail.ts` — a narrowing, not a rewrite); and the page renders live switches for a plain member with the refusal discovered only after the click (`ComponentNavView` has no gate; ledger -reads are deliberately open per `sys-metadata-activation.object.ts:153-157` — record the +reads are deliberately open per `sys-metadata-activation.object.ts` — record the posture, then decide which shape is wanted). ### 8b. Docs drift (PD#10 class — file as docs fixes, not checklist items) -- **`content/docs/kernel/contracts/metadata-service.mdx:211-324`** teaches the superseded +- **`content/docs/kernel/contracts/metadata-service.mdx`** teaches the superseded three-layer overlay protocol as the customization architecture — which ADR-0126 §6.4 forbids citing — and its worked example overlays an **`object`** (tier B, `allowOrgOverride:false`): the exact `NOT_OVERRIDABLE` phantom write the §6.1 wall refuses. -- **`content/docs/protocol/objectui/concept.mdx:415-450`** (+ `index.mdx:446`) promises +- **`content/docs/protocol/objectui/concept.mdx`** (+ `index.mdx`) promises per-tenant field-level object customization ("Make phone required", "Add custom field vip_status") — contradicts Regime E and the sentence now shipped at - `capabilities/integrations.mdx:17` ("not by editing what shipped"). + `capabilities/integrations.mdx` ("not by editing what shipped"). - **`build-without-code.mdx`** routes no-code admins to a code-only mechanism without saying so — extension packages ship in code with the package - (`data-modeling/object-extensions.mdx:22,28`); one clause ("via an extension package + (`data-modeling/object-extensions.mdx,28`); one clause ("via an extension package your developer ships") closes it. - **The shipped Regime-C doors are undocumented**: no docs page for `/automation/:name/clone`, `/actions/_activation/:object/:action`, the subflow 409, or the §5 operator gate; `ACTION_DISABLED` appears only in the generated ledgers; - `references/api/automation-api.mdx:22`'s toggle row predates the durable/gated semantics. + `references/api/automation-api.mdx`'s toggle row predates the durable/gated semantics. - **ADR-0126's header still reads `Status: Proposed`** while all nine epic PRs are merged at af56546 — flip it (the acceptance act evidently happened). @@ -339,7 +339,7 @@ posture, then decide which shape is wanted). - **A1 will fail** (D16); the typed URL works and the rest of A/B/C is testable through it. - **已知边界 3 is inaccurate as worded**: with the ledger attached, `toggleFlow` writes a - row for ANY flow it holds (`packageId: ''` for non-packaged — `actions.ts:139-145` + row for ANY flow it holds (`packageId: ''` for non-packaged — `actions.ts` states the design); the non-persistent case is the ledger-less boot (D21), not "non-packaged flows" per se. - **D1's refusal on a stock (single-posture) boot is the `manage_metadata` tier** — the §5 @@ -364,11 +364,11 @@ posture, then decide which shape is wanted). ### 8e. Checked and CLEAN (so the next sweep does not re-derive) -- **E1's three-tier language landed verbatim** at `capabilities/integrations.mdx:17` and - `build-without-code.mdx:37`; repo-wide, no unconditional "install then customize in +- **E1's three-tier language landed verbatim** at `capabilities/integrations.mdx` and + `build-without-code.mdx`; repo-wide, no unconditional "install then customize in Studio" claim remains in `content/docs`. - **The dashboard overlay door exists** (`dashboard allowOrgOverride: true`, - `packages/spec/src/kernel/metadata-plugin.zod.ts:787`) — the display-class item asserts + `packages/spec/src/kernel/metadata-plugin.zod.ts`) — the display-class item asserts the tier-1 promise on both view and dashboard. - **`TenancyPostureSchema` is enumSource-pinnable** (direct inline `z.enum`, 3 members) — pinned on the operator-gate item. **`sys_metadata_activation.metadata_type` is NOT @@ -394,15 +394,15 @@ 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 | +| 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` (only `?` handled); `components/src/ui/sidebar.tsx` | 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`; `AiPendingActionsPage.tsx`; `AiPendingActionsInbox.tsx`; `useAiSurface.ts` | 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`, `plugin-detail/src/synth/buildDefaultPageSchema.ts`; `object.zod.ts` vs `TabbedForm.tsx` | 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`) has no label. The #4115 class recurring until the next objectui spec-pin bump. | objectui `metadata-admin/external/api.ts` + `api.test.ts`; framework `external-datasource-routes.ts` | 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,877,902`; `plugin-approvals/src/approvals-plugin.ts`; `plugin-webhooks/src/webhook-outbox-plugin.ts` | 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,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 +dispatch is wired **fail-closed** at head (`reports-plugin.ts` 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 @@ -411,24 +411,24 @@ 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` +- **`content/docs/references/api/export.mdx,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 + (`rest-server.ts`) — a caller asking for the documented `parquet` gets a CSV with a 200. -- **`capabilities/approvals.mdx:11`** counts the dead `queue` style among "eight +- **`capabilities/approvals.mdx`** 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 +- **`capabilities/integrations.mdx`** — "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 +- **`capabilities/views.mdx`** — "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" +- **`capabilities/automation.mdx`** — notifications "(in-app, email, chat)": registered + channels are inbox/email/sms only (`messaging-service-plugin.ts,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": +- **`capabilities/index.mdx,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. @@ -438,7 +438,7 @@ resolve-at-load only). (`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. + (`automation/execution.zod.ts,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 @@ -451,14 +451,14 @@ resolve-at-load only). ### 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 + the flag enforced (`external-validation-plugin.ts` 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 +- **§7b's `admin-routes.ts` 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 +- **§8a D16 is FIXED** (#12457): `setup-nav.contributions.ts` 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. @@ -492,11 +492,11 @@ resolve-at-load only). - `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`. +- MCP "on by default at /api/v1/mcp" matches `serve.ts`. - 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 + branch (`wait-node.ts`) — 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's `FeedFilterMode` imports from spec (`RecordActivityTimeline.tsx`, 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 diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md index 85107166de..f0d083d76d 100644 --- a/docs/qa/platform-checklist/README.md +++ b/docs/qa/platform-checklist/README.md @@ -127,7 +127,7 @@ copy its shape rather than inventing a second one. "call": "POST /api/v1/packages", "body": { "…": "…" }, // optional; omit for a non-body step "expect": "what a correct response looks like — and the re-run/409 caveat", - "source": "framework file:line that grounds the call and its shape" } + "source": "framework file + symbol that grounds the call and its shape" } ], "teardown": "the one call (or the cheaper discard-the-DB path) that undoes it", "knownGaps": ["where the recipe is known to be sharp — e.g. an SDK helper that drops ?package="] @@ -157,8 +157,14 @@ Why this shape: the only cleanup is the `teardown` line. That is what makes a recipe safe to replay on a live boot — and why `requires` must name the capability the recipe itself needs (e.g. a session holding `manage_metadata`) rather than assuming a bare admin session. -- **Every call cites framework source at `file:line`.** Replay them literally; if one - 4xxs, re-read the citation before assuming the recipe rotted. +- **Every call cites framework source as `file` plus the symbol it lands in.** Replay + them literally; if one 4xxs, re-read the citation before assuming the recipe rotted. + ⛔ **Never pin a source LINE NUMBER** (`file.ts:NNN`, or a bare `:NNN` continuing an + earlier citation — both spellings are refused). A line number is the only part of a + citation that rots on an edit the citation has nothing to do with, and nothing can + tell a rotted one from a fresh one — so it keeps reading as "verified against source" + while pointing somewhere else. The file plus the symbol name is already the + load-bearing half. `check:platform-checklist` fails on any that reappear. - **`why` is the debt marker.** A recipe exists because stock fixtures cannot demonstrate something — the same discipline as a coverage waiver. Landing the fixture in the showcase seeds proper retires the recipe; until then `why` says what is missing and diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md index 8a925e68e5..9acd71d549 100644 --- a/docs/qa/platform-checklist/RUNNER.md +++ b/docs/qa/platform-checklist/RUNNER.md @@ -201,7 +201,7 @@ contradicts it, and correct it here when it does. proving it was an address problem by putting a TCP forwarder on 3000, and #7538 fixed the resolution but could not fix a boot that never exports the variable. **Check:** before scoring any connector clause, `GET /api/v1/automation/connectors` and read the - instance `baseUrl` — if it says `:3000` and your server does not listen there, the + instance `baseUrl` — if it says `` and your server does not listen there, the fixture is misconfigured, not blocked. **Fix:** boot with `OS_PORT=` exported (or `SHOWCASE_SELF_URL=http://127.0.0.1:`) *in addition to* `-p `. ⛔ Do not record a connector clause as `blocked(environment)` on the strength of `fetch failed` @@ -209,7 +209,7 @@ contradicts it, and correct it here when it does. - **`ss` is not installed in these containers — read liveness with `curl`, never a socket table.** `ss` and `netstat` are both absent (`command not found`); `lsof` and `fuser` - are present. The trap is that the usual spelling hides the cause: `ss -ltn | grep :3000` + are present. The trap is that the usual spelling hides the cause: `ss -ltn | grep` sends the error to stderr and prints nothing, so a **live** server is indistinguishable from a dead one — empty stdout, exit 1, no clue why. **Check instead:** `curl -s -o /dev/null -w '%{http_code}' http://localhost:PORT/api/v1/health` (substitute diff --git a/docs/qa/platform-checklist/SWEEP.md b/docs/qa/platform-checklist/SWEEP.md index a6148c35ea..d16ab60fab 100644 --- a/docs/qa/platform-checklist/SWEEP.md +++ b/docs/qa/platform-checklist/SWEEP.md @@ -12,7 +12,7 @@ value a matrix item was pinned against (`enumSource` freshness ratchet). ⚠️ Those ratchets are **NOT wired into per-PR CI** — by maintainer decision they run on a periodic **manual** cadence (see this dir's [README.md](./README.md) "Operating cadence", -and the recorded note at `.github/workflows/lint.yml:426`). So they do **not** catch drift +and the recorded note at `.github/workflows/lint.yml`). So they do **not** catch drift on the PR that causes it; they catch it at the **next manual run**. Do not read a green ledger as "drift is already gated" — between runs, an unmapped kind or a grown enum sits undetected on `main`, which is precisely why a sweep must re-derive from source rather diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 2b0210ae7a..ecb32a9623 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2112,21 +2112,21 @@ "app": "showcase", "requires": [ "stock showcase boots the `single` tenancy posture (no tenancy service / ADR-0093 D4-D5 fallback) — CONFIRM the posture before scoring, because it decides which gate can answer at all", - "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts:1679) for POST /api/v1/automation/showcase_urgent_task_alert/toggle", + "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts) for POST /api/v1/automation/showcase_urgent_task_alert/toggle", "a packaged action for POST /api/v1/actions/_activation/showcase_task/showcase_mark_done (examples/app-showcase/src/ui/actions/index.ts)", "a runtime-authored scratch permission set with system_permissions ['manage_metadata'] granted to a fresh member via a sys_user_permission_set row (same runtime-authoring pattern access-security.fls-mask-and-strip's read half uses) — no stock showcase set grants manage_metadata" ], "knownGaps": [ - "The group/isolated legs (clauses 5) have NO stock fixture: enabling a walled posture requires the enterprise @objectstack/organizations runtime (packages/spec/src/security/tenancy-posture.ts:23-34 'Open code, entitled activation', ADR-0105 D12), no example app sets a posture, and booting OS_TENANCY_POSTURE=isolated without it is correctly REFUSED (ADR-0093 D5) — the same dead end three rounds re-derived on access-security.no-active-org-session-semantics (#9334). Those clauses are blocked(fixture); the unit pins in automated.ref are their standing oracle. Never fake a posture to tick them.", - "⚠️ On the STOCK `single` posture the §5 operator gate is INERT by design (activation-gate.ts:141-147 — postureEnforcesWall('single') is false, so the gate returns before the position is ever read). The refusal an unentitled caller gets on stock showcase is the manage_metadata AUTHORING TIER, not the operator gate — score clause 1/2 against the tier and never against §5, or the run records the wrong gate as proven." + "The group/isolated legs (clauses 5) have NO stock fixture: enabling a walled posture requires the enterprise @objectstack/organizations runtime (packages/spec/src/security/tenancy-posture.ts 'Open code, entitled activation', ADR-0105 D12), no example app sets a posture, and booting OS_TENANCY_POSTURE=isolated without it is correctly REFUSED (ADR-0093 D5) — the same dead end three rounds re-derived on access-security.no-active-org-session-semantics (#9334). Those clauses are blocked(fixture); the unit pins in automated.ref are their standing oracle. Never fake a posture to tick them.", + "⚠️ On the STOCK `single` posture the §5 operator gate is INERT by design (activation-gate.ts — postureEnforcesWall('single') is false, so the gate returns before the position is ever read). The refusal an unentitled caller gets on stock showcase is the manage_metadata AUTHORING TIER, not the operator gate — score clause 1/2 against the tier and never against §5, or the run records the wrong gate as proven." ] }, "steps": [ "boot showcase isolated; admin session; PROVE the posture is `single` (no tenancy service resolves / the deployment declares none) and record the evidence — the posture selects which gate can answer", "as the plain member: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false} — capture status, code and the EXACT message string", "as the plain member: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} — capture status, code and the exact message", - "as the plain member again: repeat the flow toggle with a deliberately INVALID body (e.g. {\"enabled\": \"false\"}) — the refusal must still be the 403, not a 400 body-validation answer (the gate runs before body validation, automation.ts:1017-1045; activation-gate.ts:128-131)", - "GET /api/v1/data/sys_metadata_activation (reads are open — sys-metadata-activation.object.ts:153-158) and confirm no row for either artifact was written by the refusals", + "as the plain member again: repeat the flow toggle with a deliberately INVALID body (e.g. {\"enabled\": \"false\"}) — the refusal must still be the 403, not a 400 body-validation answer (the gate runs before body validation, automation.ts; activation-gate.ts)", + "GET /api/v1/data/sys_metadata_activation (reads are open — sys-metadata-activation.object.ts) and confirm no row for either artifact was written by the refusals", "author the scratch manage_metadata permission set, grant it to a fresh member, and prove server-side that the member's positions do NOT include platform_admin (session row / GET /auth/get-session — never the grant gesture)", "as that member: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false} — expect 200; read the ledger row back (metadata_type 'flow', active false, organization_id NULL); then re-enable ({\"enabled\": true}) to restore the fixture", "as the same member: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} — expect 200 + the metadata_type 'action' row; re-enable to restore", @@ -2136,37 +2136,37 @@ { "clause": "flow door, stock `single` posture: the refusal an unentitled caller gets is the manage_metadata AUTHORING TIER — 403 PERMISSION_DENIED with the exact enablement sentence — and NOT the §5 operator gate, which is inert on `single`; the run must record which gate it scored", "oracle": "api", - "verify": "POST /api/v1/automation/showcase_urgent_task_alert/toggle as the plain member answers 403, code PERMISSION_DENIED, message exactly 'Enabling or disabling an automation flow requires the `manage_metadata` capability.' (FLOW_ENABLEMENT_DENY_MESSAGE, packages/runtime/src/domains/automation.ts:333-334 — the toggle-specific sentence, not the generic 'Authoring automation flows requires…' one). A message naming the tenancy posture here would mean the §5 gate answered — on `single` that is a FAIL of this clause, not a pass", + "verify": "POST /api/v1/automation/showcase_urgent_task_alert/toggle as the plain member answers 403, code PERMISSION_DENIED, message exactly 'Enabling or disabling an automation flow requires the `manage_metadata` capability.' (FLOW_ENABLEMENT_DENY_MESSAGE, packages/runtime/src/domains/automation.ts — the toggle-specific sentence, not the generic 'Authoring automation flows requires…' one). A message naming the tenancy posture here would mean the §5 gate answered — on `single` that is a FAIL of this clause, not a pass", "evidence": "the refusal trace with the full message string quoted" }, { "clause": "action door, stock `single` posture: same tier, the actions domain's own call into the shared gate — 403 PERMISSION_DENIED naming manage_metadata and the #10243 'functionally equivalent to deleting it' rationale, with subject 'a packaged action'", "oracle": "api", - "verify": "POST /api/v1/actions/_activation/showcase_task/showcase_mark_done as the plain member answers 403 PERMISSION_DENIED with the refuseUngrantedActivationAuthoring sentence (packages/runtime/src/domains/activation-gate.ts:184-203, called at domains/actions.ts:154; the posture arm at actions.ts:156 stays silent on `single`)", + "verify": "POST /api/v1/actions/_activation/showcase_task/showcase_mark_done as the plain member answers 403 PERMISSION_DENIED with the refuseUngrantedActivationAuthoring sentence (packages/runtime/src/domains/activation-gate.ts, called at domains/actions.ts; the posture arm at actions.ts stays silent on `single`)", "evidence": "the refusal trace with the message quoted" }, { "clause": "a refused caller writes nothing and learns nothing: no sys_metadata_activation row exists for either artifact after the refusals, and an unentitled caller with an INVALID body still gets the 403 — never a 400 that maps out the toggle contract (both gates run before body validation)", "oracle": "api", - "verify": "GET /api/v1/data/sys_metadata_activation filtered on the two names returns no row attributable to the refused attempts; the invalid-body probe as the plain member answers 403 PERMISSION_DENIED, not 400 (gate placement: automation.ts:1017-1045 ahead of the service probe and body checks; activation-gate.ts:128-131)", + "verify": "GET /api/v1/data/sys_metadata_activation filtered on the two names returns no row attributable to the refused attempts; the invalid-body probe as the plain member answers 403 PERMISSION_DENIED, not 400 (gate placement: automation.ts ahead of the service probe and body checks; activation-gate.ts)", "evidence": "the ledger reads + the invalid-body trace" }, { - "clause": "the §5 gate is INERT on `single`, not satisfied: a caller holding manage_metadata but NOT the platform_admin POSITION succeeds at BOTH doors, and each write lands one install-level ledger row (organization_id NULL) — install-level and org-level are the same scope on one logical tenant (activation-gate.ts:22-27,141-147)", + "clause": "the §5 gate is INERT on `single`, not satisfied: a caller holding manage_metadata but NOT the platform_admin POSITION succeeds at BOTH doors, and each write lands one install-level ledger row (organization_id NULL) — install-level and org-level are the same scope on one logical tenant (activation-gate.ts,141-147)", "oracle": "api", - "verify": "the scratch-set member's positions are proven server-side to exclude platform_admin (BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin', packages/spec/src/identity/eval-user.zod.ts:32), then both toggles answer 200 and GET /api/v1/data/sys_metadata_activation shows the metadata_type 'flow' and 'action' rows with active false, organization_id NULL; re-enable restores active true", + "verify": "the scratch-set member's positions are proven server-side to exclude platform_admin (BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin', packages/spec/src/identity/eval-user.zod.ts), then both toggles answer 200 and GET /api/v1/data/sys_metadata_activation shows the metadata_type 'flow' and 'action' rows with active false, organization_id NULL; re-enable restores active true", "evidence": "the position proof + both 200 traces + the ledger rows" }, { "clause": "walled postures (`group`/`isolated`) — BLOCKED on stock fixtures (knownGaps, #9334 precedent): an org admin holding manage_metadata is refused 403 PERMISSION_DENIED with a message naming the POSTURE and the per-artifact remedy — flows recommend the clone path, actions deliberately do NOT (operator + sibling authoring instead) — no row is written on refusal, and a caller carrying the platform_admin POSITION (not a capability) is admitted", "oracle": "test", - "verify": "stands on the unit pins until an enterprise-posture fixture exists: packages/runtime/src/domains/automation-activation-posture-gate.test.ts (#12157) and action-activation-posture-gate.test.ts (#12160) drive the posture x identity matrix against both doors and assert toggleFlow/the write was never entered on refusal; the refusal sentence and remedies are activation-gate.ts:100-118,155-163. Do not tick this clause from an HTTP probe on a faked posture", + "verify": "stands on the unit pins until an enterprise-posture fixture exists: packages/runtime/src/domains/automation-activation-posture-gate.test.ts (#12157) and action-activation-posture-gate.test.ts (#12160) drive the posture x identity matrix against both doors and assert toggleFlow/the write was never entered on refusal; the refusal sentence and remedies are activation-gate.ts,155-163. Do not tick this clause from an HTTP probe on a faked posture", "evidence": "the two test runs' output, cited by test name" }, { "clause": "engine self-invocation bypasses both tiers (isSystem, never settable from the wire) — the boot pull and ledger hydration must not be refused by the gate that guards the wire", "oracle": "test", - "verify": "the isSystem arms of the same unit pins (activation-gate.ts:139,190 — both return undefined before any check); corroborate live by the fact that a restart re-hydrates flow activations without error while the gate is armed", + "verify": "the isSystem arms of the same unit pins (activation-gate.ts,190 — both return undefined before any check); corroborate live by the fact that a restart re-hydrates flow activations without error while the gate is armed", "evidence": "test output + the boot log line" } ], @@ -2174,7 +2174,7 @@ "the §5 posture-gate sentence ('…this deployment runs the \\'single\\' tenancy posture…') appearing on a stock boot is a FAIL — the gate must not fire where postureEnforcesWall is false", "run the refused calls as ADMIN too and confirm 200 (wrong-persona both sides): the denial must come from the missing capability, not a broken route", "the two doors' tier sentences must not swap: the flow door owns its own FLOW_ENABLEMENT_DENY_MESSAGE while the action door speaks the shared activation-gate sentence — a run quoting the wrong door's string has probed the wrong door", - "absent tenancy service fails OPEN by design (activation-gate.ts:50-56,141-147): do not file a missing-refusal finding against a deployment with no tenancy service — that is ADR-0093 D4/D5, not a hole" + "absent tenancy service fails OPEN by design (activation-gate.ts,141-147): do not file a missing-refusal finding against a deployment with no tenancy service — that is ADR-0093 D4/D5, not a hole" ], "variants": [ "single (stock — gate inert, tier decides)", @@ -2192,8 +2192,8 @@ }, "source": [ "packages/runtime/src/domains/activation-gate.ts (the shared §5 gate: posture read 141-147, platform_admin POSITION test 149-150, manage_metadata tier 184-203, per-artifact remedies 100-118, fail-open rationale 50-56)", - "packages/runtime/src/domains/automation.ts:333-334 (FLOW_ENABLEMENT_DENY_MESSAGE), :471-474 (isFlowActivationWrite), :1017-1045 (gate ordering ahead of service probe and body checks)", - "packages/runtime/src/domains/actions.ts:154-157 (both tiers at the action door)", + "packages/runtime/src/domains/automation.ts (FLOW_ENABLEMENT_DENY_MESSAGE), (isFlowActivationWrite), (gate ordering ahead of service probe and body checks)", + "packages/runtime/src/domains/actions.ts (both tiers at the action door)", "packages/spec/src/security/tenancy-posture.ts (TenancyPostureSchema — the variants source; postureEnforcesWall; the ADR-0105 D12 entitlement note)", "docs/adr/0126-packaged-metadata-customization-model.md §5 (D3: install-level rows, operator-gated in multi-org postures)", "#12438 (the sweep), #12159 (ADR-0126 flow legs), #10243 (the measured incident the gate makes durable), #12157 / #12160 (the two door cards)" @@ -2221,11 +2221,11 @@ "fixtures": { "app": "showcase", "requires": [ - "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts:1679)", + "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts)", "capture the flow's full current definition BEFORE any probe (GET /api/v1/automation/showcase_urgent_task_alert) — it is the restore payload" ], "knownGaps": [ - "the acceptance is the ADR-0126 §2 PARITY PROMISE ('the packaged base is locked — in-place edit refused loudly at the write door'), not today's behavior: as of #12438 the /automation door has NO lock — PUT/DELETE /automation/:name reach registerFlow/unregisterFlow with only the manage_metadata authoring gate in front (packages/runtime/src/domains/automation.ts:1826-1865), and the engine has zero lock/provenance check on that path (packages/services/service-automation/src/engine.ts:2625-2730). Clauses 2-3 are EXPECTED FAILS: a 200 there, where /meta refuses the same artifact, is the product finding, tracked centrally in FOLLOW-UPS (#12438). Keep the parity promise as the acceptance so the item flips green when the door is locked, without a rewrite" + "the acceptance is the ADR-0126 §2 PARITY PROMISE ('the packaged base is locked — in-place edit refused loudly at the write door'), not today's behavior: as of #12438 the /automation door has NO lock — PUT/DELETE /automation/:name reach registerFlow/unregisterFlow with only the manage_metadata authoring gate in front (packages/runtime/src/domains/automation.ts), and the engine has zero lock/provenance check on that path (packages/services/service-automation/src/engine.ts). Clauses 2-3 are EXPECTED FAILS: a 200 there, where /meta refuses the same artifact, is the product finding, tracked centrally in FOLLOW-UPS (#12438). Keep the parity promise as the acceptance so the item flips green when the door is locked, without a rewrite" ] }, "steps": [ @@ -2244,13 +2244,13 @@ "evidence": "both PUT traces (with and without ?package=) + the before/after /meta reads" }, { - "clause": "parity, update door: PUT /api/v1/automation/showcase_urgent_task_alert against the SAME packaged artifact is refused — ⚠️ EXPECTED FAIL today: the door runs only the manage_metadata authoring gate (automation.ts:1826-1857) and registerFlow re-registers with no lock or provenance check (engine.ts:2625-2715), so a 200 here while /meta refused the identical artifact IS the finding. Record the fail with both traces side by side; the defect is tracked centrally in FOLLOW-UPS (#12438) — do not re-file it per run", + "clause": "parity, update door: PUT /api/v1/automation/showcase_urgent_task_alert against the SAME packaged artifact is refused — ⚠️ EXPECTED FAIL today: the door runs only the manage_metadata authoring gate (automation.ts) and registerFlow re-registers with no lock or provenance check (engine.ts), so a 200 here while /meta refused the identical artifact IS the finding. Record the fail with both traces side by side; the defect is tracked centrally in FOLLOW-UPS (#12438) — do not re-file it per run", "oracle": "api", "verify": "same admin session, same artifact, same-shape body at both doors; the verdicts must MATCH. A 2xx on /automation with a mutated GET read-back, paired with the /meta refusal from clause 1, is a fail of this clause and the expected present-day outcome", "evidence": "the /automation PUT trace + the mutated (or unchanged) GET read-back, paired with clause 1's refusal" }, { - "clause": "parity, delete door: DELETE /api/v1/automation/showcase_urgent_task_alert is refused for the same reason — ⚠️ EXPECTED FAIL today (unregisterFlow, engine.ts:2717-2730, removes the shipped flow from the live engine with no check; 'delete first, refuse second' is the exact shape the #10145 measurement recorded at this door before the capability gate existed, and the lock half is still missing)", + "clause": "parity, delete door: DELETE /api/v1/automation/showcase_urgent_task_alert is refused for the same reason — ⚠️ EXPECTED FAIL today (unregisterFlow, engine.ts, removes the shipped flow from the live engine with no check; 'delete first, refuse second' is the exact shape the #10145 measurement recorded at this door before the capability gate existed, and the lock half is still missing)", "oracle": "api", "verify": "DELETE answers >=400 and the flow still serves; a 200 followed by a 404 on GET /api/v1/automation/showcase_urgent_task_alert is the fail (and the deletion this item's restore step exists to undo)", "evidence": "the DELETE trace + the follow-up GET" @@ -2272,8 +2272,8 @@ "dispatcher-vs-hono-route" ], "source": [ - "packages/runtime/src/domains/automation.ts:1826-1865 (PUT/DELETE /automation/:name → registerFlow/unregisterFlow, manage_metadata gate only)", - "packages/services/service-automation/src/engine.ts:2625-2730 (registerFlow/unregisterFlow — zero lock/provenance check on this path)", + "packages/runtime/src/domains/automation.ts (PUT/DELETE /automation/:name → registerFlow/unregisterFlow, manage_metadata gate only)", + "packages/services/service-automation/src/engine.ts (registerFlow/unregisterFlow — zero lock/provenance check on this path)", "docs/adr/0126-packaged-metadata-customization-model.md §2 (Regime C: 'the packaged base is locked — in-place edit refused loudly at the write door') + §6 wall 2 (sole definition ledger)", "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (the same lock implemented at the permission-set data door — the regime's first instance, proving the refusal shape the /automation door lacks)", "cross-ref access-security.readonly-package-locks-studio (the /meta door's locked-base class on objects) + access-security.owd-save-gate rev 3 (why the /meta control leg records the answering layer instead of assuming it)", @@ -2304,7 +2304,7 @@ "app": "showcase", "requires": [ "a package-declared permission set: showcase_contributor (declared by com.example.showcase in examples/app-showcase/src/security/permission-sets.ts, projected to a sys_permission_set row at boot) — the SchemaRegistry, not the row's managed_by column, is what the lock reads (packaged-permission-set-lock.ts)", - "the Setup permission-sets surface rendering the object's own actions (Activate / Deactivate / Clone on locations list_item + record_header — packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:41-155)", + "the Setup permission-sets surface rendering the object's own actions (Activate / Deactivate / Clone on locations list_item + record_header — packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts)", "member M granted showcase_contributor, with a showcase_contributor-only verb to probe (e.g. a project PATCH the everyone baseline does not grant — compute the baseline union per access-security.crud-permission-matrix's knownGaps before picking it)" ], "knownGaps": [ @@ -2315,7 +2315,7 @@ "boot showcase with the console; admin session; provision member M (grant showcase_contributor); baseline: as M exercise the contributor-granted probe verb — 2xx; count sys_metadata_activation rows (GET /api/v1/data/sys_metadata_activation — reads are open)", "lock, API: as admin PATCH /api/v1/data/sys_permission_set/ with a DEFINITION change (e.g. an object_permissions tweak) — capture the 403 and its message verbatim", "lock, UI: attempt the same edit through the Setup/Studio permission-set surface and capture what renders (the server refusal text, not a local fallback)", - "row-state carve-out: run the Deactivate action from the Setup list (or PATCH /api/v1/data/sys_permission_set/ {\"active\": false} — exactly what the action sends, bodyExtra sys-permission-set.object.ts:66) — expect 2xx, NOT the lock", + "row-state carve-out: run the Deactivate action from the Setup list (or PATCH /api/v1/data/sys_permission_set/ {\"active\": false} — exactly what the action sends, bodyExtra sys-permission-set.object.ts) — expect 2xx, NOT the lock", "as M: repeat the probe verb — now refused; re-read the row (active false) and confirm M's sys_user_permission_set assignment row still exists (deactivation keeps assignments, per the action's own confirmText)", "clone: run the Clone action on showcase_contributor (POST /api/v1/data/sys_permission_set — new label + snake_case name both required); read the clone back and field-compare every definition facet against the base; attempt the clone again with the SAME name — 409", "clone is org-owned: PATCH the clone's definition (same shape step 2 sent at the base) — 2xx; grant the clone to a fresh member and prove one of its verbs live", @@ -2326,11 +2326,11 @@ { "clause": "locked base: an in-place DEFINITION write against a package-declared set is refused 403 NOT_OVERRIDABLE with the #11513 message naming the sanctioned path (clone it), and the stored definition is unchanged — fail-closed: undeterminable provenance refuses too, with its own distinct message", "oracle": "api", - "verify": "the PATCH answers 403, code NOT_OVERRIDABLE, message from PackagedPermissionSetLockedError (packages/plugins/plugin-security/src/packaged-permission-set-lock.ts:265-283 — names the package and the Clone action; the provenance-unknown refusal at :286-299 is a different sentence, distinguish by message); a re-read shows the definition facets unchanged", + "verify": "the PATCH answers 403, code NOT_OVERRIDABLE, message from PackagedPermissionSetLockedError (packages/plugins/plugin-security/src/packaged-permission-set-lock.ts — names the package and the Clone action; the provenance-unknown refusal is a different sentence, distinguish by message); a re-read shows the definition facets unchanged", "evidence": "the PATCH trace with message quoted + the before/after reads" }, { - "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out runs BEFORE the lock — permission-set-projection.ts:1128-1134) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", + "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out runs BEFORE the lock — permission-set-projection.ts) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", "oracle": "api", "verify": "deactivate 2xx (a 403 here is the lock swallowing the carve-out — a FAIL that breaks the shipped Deactivate action); M's probe verb >=400 while inactive, 2xx after reactivation; sys_user_permission_set row for M present at every step", "evidence": "the lifecycle traces + M's before/during/after probes + the assignment-row reads" @@ -2338,7 +2338,7 @@ { "clause": "clone contract (#11703 regression guard): new label AND machine name mandatory; ALL definition facets carried — description, object_permissions, field_permissions, AND the three #11703 dropped ones (system_permissions, row_level_security, tab_permissions); admin_scope deliberately ABSENT (ruled 2026-08-24, stated in the action's own description); the clone starts active; a taken name answers 409; no linkage column (no cloned_from anywhere — amendment ruling 2)", "oracle": "api", - "verify": "field-compare the clone row against the base per the clone_permission_set params list (sys-permission-set.object.ts:137-154 — the params list IS the payload); admin_scope empty/default on the clone; duplicate-name POST → 409 (projection duplicate check); no cloned_from/replaced_by key on the row", + "verify": "field-compare the clone row against the base per the clone_permission_set params list (sys-permission-set.object.ts — the params list IS the payload); admin_scope empty/default on the clone; duplicate-name POST → 409 (projection duplicate check); no cloned_from/replaced_by key on the row", "evidence": "the clone POST + the field-by-field diff + the 409 trace" }, { @@ -2361,7 +2361,7 @@ } ], "negative": [ - "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (permission-set-projection.ts:1140-1144 says so in as many words)", + "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (permission-set-projection.ts says so in as many words)", "a clone missing system_permissions, row_level_security or tab_permissions is the exact #11703 silent-grant-loss regression this item guards — diff every facet, not just the two that were never dropped", "the two 403 sentences (packaged-locked vs provenance-unknown) must not be conflated: both are NOT_OVERRIDABLE, and only the message says whether the lock decided or failed closed", "cross-refs, to keep one defect one count: the /meta-door package lock on objects is access-security.readonly-package-locks-studio; the WRITABLE-set edit loop through /meta/permission is access-security.permission-matrix-edit-loop (its fixture note explicitly excludes locked package sets — this item is that excluded case, at the DATA door); grant enforcement itself is access-security.crud-permission-matrix" @@ -2377,8 +2377,8 @@ }, "source": [ "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", - "packages/plugins/plugin-security/src/permission-set-projection.ts:1076-1169 (insert-path lock + duplicate 409; the #4669 row-state carve-out at :1128-1134 ordered BEFORE the update-path lock at :1135-1167)", - "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:41-155 (activate/deactivate bodyExtra {active}, clone_permission_set params — the payload contract)", + "packages/plugins/plugin-security/src/permission-set-projection.ts (insert-path lock + duplicate 409; the #4669 row-state carve-out ordered BEFORE the update-path lock)", + "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts (activate/deactivate bodyExtra {active}, clone_permission_set params — the payload contract)", "docs/adr/0126-packaged-metadata-customization-model.md §1.1 (the #11513 machinery, named as the regime's first instance), §8 item 3 (permission sets converge later; the landed shape stays valid meanwhile)", "#11513 (lock the base, clone to customize — the 2026-08-24 ruling), #11703 (the three dropped clone facets), #12159 (ruling 2026-08-26: no ledger rows for permission sets pending the convergence card), #12438 (the sweep)" ], @@ -2406,11 +2406,11 @@ "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" + "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), 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; DATASOURCE_ADMIN_CAPABILITY), and admin_full_access carries that capability (packages/spec/src/identity/eval-user.zod.ts) — 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" + "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) — 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": [ @@ -2429,7 +2429,7 @@ "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)", + "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)", "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" @@ -2441,7 +2441,7 @@ "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)", + "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; sink) — 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" @@ -2453,14 +2453,14 @@ "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)", + "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)", "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", + "no runtime write reaches the anchor: OS_PLATFORM_OWNER_EMAIL is declared a NON-TABLE derivation input (admin-standing-surface.ts) — 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" @@ -2470,7 +2470,7 @@ "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)", + "comma-separated list — duplicates collapsed, trim+lowercase normalization (platform-admin.ts)", "malformed entry (whole-variable refusal, loud once)" ], "traps": [ @@ -2482,12 +2482,12 @@ "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)", + "packages/core/src/security/resolve-authz-context.ts (§6b-config — the config anchor inside the ONE derivation site; additive, never subtractive) + (hasPlatformAdminStanding, the id-shaped projection)", + "packages/core/src/security/platform-admin.ts (parsePlatformAdminEmails whole-variable refusal, loud-once resolve, matchesConfiguredPlatformAdmin verified-only, reportLegacyPlatformAdminGrant)", + "packages/core/src/security/admin-standing-surface.ts (the env declared a non-table derivation input — no break-glass write can reach it)", + "packages/types/src/env.ts,200 (PLATFORM_OWNER_EMAIL_ENV / resolvePlatformOwnerEmail)", + "packages/plugins/plugin-auth/src/auth-plugin.ts (#11343 — the dev seed stamps its admin's address verified)", + "packages/services/service-datasource/src/admin-routes.ts,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": [ @@ -2521,7 +2521,7 @@ "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" + "the SecurityPlugin-absent fail-open branch (current-user-endpoints.ts 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": [ @@ -2544,19 +2544,19 @@ { "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)", + "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)", "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)", + "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)", "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", + "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 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)", + "verify": "the three anonymous traces match those exact statuses and bodies (current-user-endpoints.ts)", "evidence": "the three traces" }, { @@ -2592,8 +2592,8 @@ "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)", + "packages/plugins/plugin-hono-server/src/current-user-endpoints.ts (/auth/me/permissions aggregation + most-permissive merge), (/auth/me/localization), (/me/apps requiredPermissions/tabPermissions filter), (the /api/v1 prefix)", + "packages/core/src/security/auth-gate.ts (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" @@ -2623,7 +2623,7 @@ "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" + "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 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", @@ -2645,39 +2645,39 @@ "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", + "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), 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)", + "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,516-521), and created_at is the VIEW instant, not the flush instant", "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)", + "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)", "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", + "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) 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)", + "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)", "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)", + "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) 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)", "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": [ @@ -2698,8 +2698,8 @@ "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)", + "packages/plugins/plugin-audit/src/read-audit.ts (READ_AUDIT_ACTION, installReadAuditWriter opt-in + exclusion filter, extractDetailReadId, buildRow nulls + view instant, isSystem/no-principal skips, batcher)", + "packages/plugins/plugin-audit/src/audit-plugin.ts (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)" ], @@ -2733,13 +2733,13 @@ ], "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" + "the ai_conversations transcript branch (SharedRecordPage.tsx,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 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", + "in a FRESH anonymous browser context open /s/: screenshot; then read the DOM — the page renders OUTSIDE the authenticated shell (App.tsx): '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", @@ -2749,19 +2749,19 @@ { "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)", + "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)", "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", + "verify": "the three round-trips (SharedRecordPage.tsx,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", + "verify": "each terminal state's screenshot shows the designed message (SharedRecordPage.tsx,178-186); the paired network bodies are record-free", "evidence": "screenshots + the 404/410 bodies" }, { @@ -2773,13 +2773,13 @@ { "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)", + "verify": "fresh-context load of the plain token shows the standalone layout (App.tsx 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" + "a DOM assertion made before a screenshot confirms render is not evidence — the loading state (SharedRecordPage.tsx) reads as an empty page to a premature dump" ], "variants": [ "plain link (renders minus redactions)", @@ -2802,8 +2802,8 @@ "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/App.tsx (the /s/:token route, mounted outside the authenticated shell)", + "objectui apps/console/src/pages/SharedRecordPage.tsx (status folds, password form, error states, generic record render + hidden-fields notice, ai_conversations branch,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)" diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index 8e0c497154..07739a271b 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -605,7 +605,7 @@ "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)", + "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)", "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" @@ -620,7 +620,7 @@ { "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)", + "verify": "settled screenshots of shell chrome + Home show none of the affordances; post-screenshot DOM reads confirm; ⌘⇧I produces no mount (ConsoleLayout.tsx gates the listener on dockEnabled = the same signal)", "evidence": "the region screenshots + the ⌘⇧I non-event" }, { @@ -630,7 +630,7 @@ "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)", + "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 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" @@ -642,7 +642,7 @@ "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", + "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) — 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" @@ -656,13 +656,13 @@ ], "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)", + "objectui packages/app-shell/src/hooks/useAiSurface.ts,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 (FAB + chat dock + ⌘⇧I all gated on showChatbot/dockEnabled)", + "objectui packages/app-shell/src/layout/AppHeader.tsx (top-bar AI entry gated on the same hook) + console/home/HomeLayout.tsx (Home CTAs)", + "objectui packages/app-shell/src/console/ConsoleShell.tsx (RequireAiSurface — waits for resolve, splash-preserving redirect, objectui#6507)", + "objectui apps/console/src/pages/system/SystemHubPage.tsx (the 'AI Approvals' card built UNconditionally — the K2 gap) + AppContent.tsx (the system/ai-approvals route) + pages/system/AiPendingActionsPage.tsx (thin wrapper, 'Polled every 5 seconds')", + "objectui packages/plugin-chatbot/src/usePendingActions.ts,217-300 (cookie-only call(), error → error.message, pollInterval 5000 re-arming regardless of errors) + AiPendingActionsInbox.tsx (destructive alert + the error-blind 'No actions waiting' empty state)", + "packages/runtime/src/domains/ai.ts (#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": [ diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 6840dc14d2..dfd22d7004 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -663,8 +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 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 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, #7526 — note the /api/settings base, NOT /api/v1) and GET /api/v1/datasources (datasource-route-ledger.ts, #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 — 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)" @@ -719,7 +719,7 @@ "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", + "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), 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" @@ -789,8 +789,8 @@ "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-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/services/service-datasource/src/admin-routes.ts (the #9391/#9593 manage_platform_settings floor on all eleven routes, drivers catalog included)", + "packages/metadata/src/metadata-route-ledger.ts (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)", @@ -1611,13 +1611,13 @@ ] }, "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", + "read the two enums and pin the matrix: AggregationFunction (packages/spec/src/data/query.zod.ts — 6 members: count/sum/avg/min/max/count_distinct, with retirement error messages wired for array_agg/string_agg) and DateGranularity (— 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" + "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): 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); 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 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": [ { @@ -1633,19 +1633,19 @@ "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", + "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 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)", + "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 — 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)", + "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 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" @@ -1687,19 +1687,19 @@ "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)", + "packages/spec/src/data/query.zod.ts (AggregationFunction + ARRAY_AGG_RETIRED/STRING_AGG_RETIRED error map), (DateGranularity), GroupByNodeSchema (dateGranularity spelling)", + "packages/objectql/src/in-memory-aggregation.ts (the six function arms incl. #11152 boolean-as-number and the #10576 per-aggregation filter; bucketDateValue ISO shapes; the #3839 real-null bucket rule at the module head)", + "packages/spec/src/data/aggregation-policy.ts (emptyGroupValueFor — the ruled empty-set identities; objectui#3136)", + "packages/runtime/src/action-execution.ts (the aggregate door: engine-only, at-least-one-aggregation, the raw-rows degradation hazard)", + "packages/rest/src/rest-server.ts (POST /data/:object/query — the wire door that accepts groupBy/aggregations)", + "packages/metadata-protocol/src/protocol.ts (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", + "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. 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" } ] @@ -1719,7 +1719,7 @@ "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)", + "the two stock formula fields for the live leg: showcase_field_zoo.f_formula (field-zoo.object.ts) and showcase_project.budget_remaining (project.object.ts)", "runtime metadata authoring (PUT /meta/object on a scratch object) for the stdlib-through-the-live-door leg" ], "knownGaps": [ @@ -1727,13 +1727,13 @@ ] }, "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", + "count the registration chain: packages/formula/src/stdlib.ts registerStdLib 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) 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", + "mixed-arithmetic overloads (registerNumericCoercions, stdlib.ts): 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" + "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) — so the read answers 200 with the field null and no error surfaced (#3306 class; stdlib.ts 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": [ { @@ -1769,7 +1769,7 @@ { "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", + "verify": "the probe's captured behavior, compared against the engine.ts 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" } ], @@ -1784,10 +1784,10 @@ "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)", + "packages/formula/src/stdlib.ts (registerStdLib — the 27 registerFunction calls; calendarDayUtc ADR-0053 D1; addMonthsUtc clamp; the floor/ceil #3306 note), (registerNumericCoercions #1928)", + "packages/objectql/src/engine.ts (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 (the advertised catalog the matrix cross-checks)", + "examples/app-showcase/src/data/objects/field-zoo.object.ts + project.object.ts (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)" @@ -1817,7 +1817,7 @@ "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", + "stock system objects as free negatives — no provisioning at all: sys_session apiMethods ['get','list'] (sys-session.object.ts), sys_device_code ['get'] (sys-device-code.object.ts), sys_api_key ['get','list','update'] (sys-api-key.object.ts), and sys_oauth_consent enable.apiEnabled false (sys-oauth-consent.object.ts) for the 404 fork", "runtime metadata authoring (manage_metadata) for the scratch-object legs" ], "knownGaps": [ @@ -1825,20 +1825,20 @@ ] }, "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)", + "read the contract sources: ApiMethod (packages/spec/src/data/object.zod.ts — SIX authored primitives get/list/create/update/delete/bulk) and the derivation table (packages/spec/src/data/api-derivation.ts — resolveEffectiveApiMethods: undefined/null → unrestricted, [] or stripped-to-empty or NON-ARRAY → deny-all, subset → restricted closure; isApiOperationAllowed: 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", + "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 — 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 — 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 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) 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)", + "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), 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)", + "verify": "the sys_oauth_consent 404 vs the sys_session/sys_device_code 405s — codes, messages, and the fork per apiAccessDenialFromEnable (rest-server.ts)", "evidence": "the captured bodies" }, { @@ -1854,7 +1854,7 @@ "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)", + "clause": "bulk∧child and writeMode precision: a bulk door (createMany/updateMany/deleteMany, per-object batch — each passes bulkChild, rest-server.ts) 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" @@ -1872,7 +1872,7 @@ "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)", + "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)", "oracle": "api", "verify": "the batch refusal + a follow-up read proving the valid sibling create never persisted", "evidence": "the trace + the absent-row read" @@ -1903,12 +1903,12 @@ "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)", + "packages/spec/src/data/object.zod.ts (ApiMethod — the six primitives), (LEGACY_API_METHODS + LEGACY_API_METHOD_GUIDANCE), (stripLegacyApiMethods — strip-and-warn + the deny-all cliff), (the z.preprocess wiring)", + "packages/spec/src/data/api-derivation.ts (resolveEffectiveApiMethods three-state, isApiOperationAllowed bulk∧child + writeMode precision, effectiveOperationsArray)", + "packages/rest/src/rest-server.ts (apiAccessDenialFromEnable — the 404/405 fork + effective allowed[]), (enforceApiAccess — external boundary only, default-allow, #3770 unknown-object pass-through), (writeMode-precise import), (batch per-op before txn), (bulk doors with bulkChild)", + "packages/objectql/src/registry.ts (warnStrippedLegacyApiMethods — the per-object registration diagnostic)", + "packages/spec/src/api/error-code-ledger.zod.ts (OBJECT_API_DISABLED, OBJECT_API_METHOD_NOT_ALLOWED)", + "packages/platform-objects/src/identity/sys-session.object.ts + sys-device-code.object.ts + sys-api-key.object.ts + sys-oauth-consent.object.ts (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)" ], @@ -1916,7 +1916,7 @@ { "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", + "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 wired via z.preprocess (the register's '' pointed at the guidance table); the enforcement docblock is rest-server.ts with the denial producer; 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" } ] diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index bf6950a686..6e52a1261d 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -103,8 +103,8 @@ "app": "showcase", "requires": ["a quorum (minApprovals: 2) request whose three position approvers resolve to three distinct users"], "knownGaps": [ - "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" + "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) 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) and Ada Auditor deliberately holds ONLY auditor, 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), 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 — 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)" }, @@ -164,7 +164,7 @@ "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": 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" } + { "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 and Ada only auditor. 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" } ] }, { @@ -495,8 +495,8 @@ "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)", - "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)" + "packages/plugins/plugin-approvals/src/approval-service.ts (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 (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" }, @@ -1096,11 +1096,11 @@ "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)" + "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; NOTIFICATION_EVENT_OBJECT = 'sys_notification') — 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" + "the EXPIRED leg has no in-session clock: ACTION_TOKEN_TTL_MS is 72h (approval-service.ts) 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": [ @@ -1161,11 +1161,11 @@ ], "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/approvals-plugin.ts (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 (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", + "packages/plugins/plugin-approvals/src/approval-service.ts (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 — which refusal shapes carry summaryRows and which do not)", + "packages/services/service-messaging/src/messaging-service.ts + (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": [ @@ -1185,7 +1185,7 @@ "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", + "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), 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": [ @@ -1253,10 +1253,10 @@ ], "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)", + "packages/spec/src/automation/approval.zod.ts (ApproverType, 10 members incl. deprecated role + dead queue), (DEPRECATED_APPROVER_TYPES role→org_membership_level + canonicalApproverType), (NON_AUTHORABLE_APPROVER_TYPES), (onEmptyApprovers admin_rescue|fail|auto_approve, default admin_rescue)", + "packages/plugins/plugin-approvals/src/approval-service.ts (expandApprovers — per-spec group tagging; expression resolved outside the flat contract), (resolveApproverSpec — role warn, per-type branches, queue warn, #3807 expanded-to-nobody warn, literal type:value fallback), (expandBusinessUnitUsers — active-descendant BFS + membership union), (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)", + "examples/app-showcase/src/data/seed/index.ts (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": [ @@ -1286,7 +1286,7 @@ "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'", + "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 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", @@ -1296,7 +1296,7 @@ { "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", + "verify": "GET the trigger record after each transition: the declared field carries exactly the transition's value (write sites: pending · final · recalled · auto-reject · returned). 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" }, { @@ -1321,7 +1321,7 @@ { "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)", + "verify": "with approvalStatusField naming a nonexistent field: the decide answers 2xx, the request finalizes, and the log carries '[approvals] mirrorStatusField failed: …' (the try/catch)", "evidence": "the successful decision reads + the warn line" } ], @@ -1333,9 +1333,9 @@ "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/spec/src/automation/approval.zod.ts (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 (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 (dead-run sweep, deliberately user-less)", + "packages/plugins/plugin-approvals/src/lifecycle-hooks.ts (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": [ @@ -1408,7 +1408,7 @@ ], "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)", + "objectui apps/console/src/pages/system/ApprovalsInboxPage.tsx (the list keyboard effect — j/k/arrows/Enter/x/space/a/r; ownership guards: selectedId/dialog yield, INPUT/TEXTAREA/SELECT/contentEditable, [role=alertdialog]), (drawer ←/→ walk), (quickDecidable = isActionable AND no declared decision outputs, #2829), (inlineApprove/inlineReject → approvalsApi.approve/reject — the same routes as the buttons), (the focus ring classes), (the hint bar copy), (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": [ diff --git a/docs/qa/platform-checklist/areas/attachments-storage.json b/docs/qa/platform-checklist/areas/attachments-storage.json index fd0dc8812b..9e2e56f058 100644 --- a/docs/qa/platform-checklist/areas/attachments-storage.json +++ b/docs/qa/platform-checklist/areas/attachments-storage.json @@ -20,14 +20,14 @@ "call": "POST /api/v1/packages", "body": { "manifest": { "id": "com.objectstack.qa.attachments", "name": "QA attachments fixture", "version": "1.0.0", "type": "app" }, "enableOnInstall": true }, "expect": "2xx with the installed package echoed back. A re-run against a live DB 409s on the duplicate id — send `overwrite: true` deliberately rather than adding it by reflex (the guard exists so a second install never silently clobbers the first).", - "source": "POST /api/v1/packages is the dispatcher install route (packages/rest/src/rest-route-ledger.ts:312 note; client `packages.install`); the body shape { manifest, enableOnInstall?, overwrite? } is URL- and body-pinned in packages/client/src/client.test.ts:2144-2166" + "source": "POST /api/v1/packages is the dispatcher install route (packages/rest/src/rest-route-ledger.ts note; client `packages.install`); the body shape { manifest, enableOnInstall?, overwrite? } is URL- and body-pinned in packages/client/src/client.test.ts" }, { "step": 2, "call": "PUT /api/v1/meta/object/qa_vault?package=com.objectstack.qa.attachments", "body": { "name": "qa_vault", "label": "QA Vault", "sharingModel": "private", "enable": { "files": true }, "fields": { "name": { "type": "text", "label": "Name", "required": true } } }, "expect": "2xx. This is THE object the area was missing: private OWD (owner-only) AND attachments-enabled, so a member who is not the owner genuinely cannot read the parent and the 403 deny side becomes provable.", - "source": "`sharingModel` is a TOP-LEVEL object key, enum ['private','public_read','public_read_write','controlled_by_parent'] (packages/spec/src/data/object.zod.ts:1827; ADR-0090 D4 — legacy aliases removed). `enable.files` is the #2727 attachments opt-in, default false (object.zod.ts:281). `?package=` is read as `query.package` and threaded to saveMetaItem as packageId (packages/runtime/src/domains/meta.ts:262,319)" + "source": "`sharingModel` is a TOP-LEVEL object key, enum ['private','public_read','public_read_write','controlled_by_parent'] (packages/spec/src/data/object.zod.ts; ADR-0090 D4 — legacy aliases removed). `enable.files` is the #2727 attachments opt-in, default false (object.zod.ts). `?package=` is read as `query.package` and threaded to saveMetaItem as packageId (packages/runtime/src/domains/meta.ts,319)" }, { "step": 3, @@ -41,7 +41,7 @@ "call": "PUT /api/v1/meta/object/qa_nofiles?package=com.objectstack.qa.attachments", "body": { "name": "qa_nofiles", "label": "QA No Files", "sharingModel": "public_read", "fields": { "name": { "type": "text", "label": "Name", "required": true } } }, "expect": "2xx. `enable.files` is OMITTED deliberately (it defaults to false) — this is the FILES_DISABLED probe target, purpose-built rather than borrowed from showcase_account, so the negative keeps holding if showcase ever enables files on its own objects.", - "source": "packages/spec/src/data/object.zod.ts:281 — enable.files defaults to false; 'Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike' (re-quoted post-#10733; the prior citation ended at 'creation is rejected', which #10170 made stale — see attach-requires-parent-edit clause 5)" + "source": "packages/spec/src/data/object.zod.ts — enable.files defaults to false; 'Opt-in: true surfaces the panel and permits attachments to target this object; otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike' (re-quoted post-#10733; the prior citation ended at 'creation is rejected', which #10170 made stale — see attach-requires-parent-edit clause 5)" }, { "step": 5, @@ -56,15 +56,15 @@ "source": "sys_user_permission_set insert in system context, mirroring showcase-permission-zoo.dogfood.test.ts — the binding shape access-security.json's permission-set items already replay. NOTE (ADR-0090 D5): every authenticated member ALSO holds the everyone baseline additively, so persona B's effective access is the UNION of its grants and that baseline — compute the union before calling a read 'invisible'." } ], - "teardown": "DELETE /api/v1/packages/com.objectstack.qa.attachments (client `packages.uninstall`, ledgered at rest-route-ledger.ts:317) — or simply discard the isolated file DB, which is the cheaper path and the one an isolated boot makes free.", + "teardown": "DELETE /api/v1/packages/com.objectstack.qa.attachments (client `packages.uninstall`, ledgered at rest-route-ledger.ts) — or simply discard the isolated file DB, which is the cheaper path and the one an isolated boot makes free.", "knownGaps": [ - "The SDK helper `meta.saveItem(type, name, item)` does NOT send the `?package=` query — it PUTs the bare path (packages/client/src/index.ts:701-707). Steps 2-4 must therefore be issued as raw HTTP with the query string appended, or the scratch objects land outside the package and the teardown above will not take them with it.", + "The SDK helper `meta.saveItem(type, name, item)` does NOT send the `?package=` query — it PUTs the bare path (packages/client/src/index.ts). Steps 2-4 must therefore be issued as raw HTTP with the query string appended, or the scratch objects land outside the package and the teardown above will not take them with it.", "Field-level detail of the two personas' permission sets (which verbs each set grants) is recorded here as intent, not as a verbatim payload: run #7635 provisioned them through a runtime permission-set/position binding whose exact set body was not captured in the run record. Replay step 6 by the stated OUTCOME (read-not-edit for A, baseline-only for B) and verify it directly, rather than trusting a payload nobody pinned." ] }, "qa-media-constraints": { "title": "Scratch object qa_media carrying FIELD-level accept/maxSize (plus an extension-only accept field)", - "why": "The showcase declares accept/maxSize ONLY on action params (examples/app-showcase/src/ui/actions/index.ts:334,337 — the dialog-widget/ADR-0059 upload-guard lane), never on an object FIELD, so the server-side record-write re-enforcement (field-accept-maxsize-server-enforced) has no stock target: without this recipe every clause of that item is blocked(fixture) on every run. Landing one constrained field in the showcase seeds proper would retire this recipe.", + "why": "The showcase declares accept/maxSize ONLY on action params (examples/app-showcase/src/ui/actions/index.ts,337 — the dialog-widget/ADR-0059 upload-guard lane), never on an object FIELD, so the server-side record-write re-enforcement (field-accept-maxsize-server-enforced) has no stock target: without this recipe every clause of that item is blocked(fixture) on every run. Landing one constrained field in the showcase seeds proper would retire this recipe.", "provenance": "authored from source in the 2026-08-20 scoped scan-functionality sweep (claude/new-session-0pv25p); call shapes copied from qa-scratch-authz (#7670), constraint keys from the FieldSchema declaration — not yet proven by a live run", "app": "showcase", "requires": [ @@ -77,20 +77,20 @@ "call": "POST /api/v1/packages", "body": { "manifest": { "id": "com.objectstack.qa.media", "name": "QA media-constraints fixture", "version": "1.0.0", "type": "app" }, "enableOnInstall": true }, "expect": "2xx with the installed package echoed back. The id is deliberately DISTINCT from qa-scratch-authz's com.objectstack.qa.attachments so the two recipes install and tear down independently. Re-run against a live DB 409s on the duplicate id — send `overwrite: true` deliberately, never by reflex.", - "source": "same grounding as qa-scratch-authz step 1: packages/rest/src/rest-route-ledger.ts:312 note; body shape pinned in packages/client/src/client.test.ts:2144-2166" + "source": "same grounding as qa-scratch-authz step 1: packages/rest/src/rest-route-ledger.ts note; body shape pinned in packages/client/src/client.test.ts" }, { "step": 2, "call": "PUT /api/v1/meta/object/qa_media?package=com.objectstack.qa.media", "body": { "name": "qa_media", "label": "QA Media", "sharingModel": "public_read_write", "fields": { "name": { "type": "text", "label": "Name", "required": true }, "poster": { "type": "image", "label": "Poster", "accept": ["image/png", "image/jpeg"], "maxSize": 1048576 }, "doc": { "type": "file", "label": "Doc", "accept": [".pdf"] } } }, "expect": "2xx. `poster` is the MIME-entry + maxSize probe; `doc` (accept = ['.pdf'] ONLY, no maxSize) is the extension-entry probe whose dotless-filename hole is a documented boundary of the enforcement. `enable.files` is deliberately ABSENT: field-owned files ride file-reference-lifecycle.ts (activeFileFields keys on file-class field types), not the #2727 sys_attachment opt-in gate, so the object needs no attachments enablement.", - "source": "accept/maxSize are declared FieldSchema keys since ADR-0104 D3 wave 2 (packages/spec/src/data/field.zod.ts:876-883 — 'Offered to the file picker AND enforced on write'); authoring-call shape identical to qa-scratch-authz step 2 (meta.ts:262,319 for ?package=); file-class field set is FILE_REFERENCE_TYPES = image/file/avatar/video/audio (packages/spec/src/data/field-value.zod.ts:146-148)" + "source": "accept/maxSize are declared FieldSchema keys since ADR-0104 D3 wave 2 (packages/spec/src/data/field.zod.ts — 'Offered to the file picker AND enforced on write'); authoring-call shape identical to qa-scratch-authz step 2 (meta.ts,319 for ?package=); file-class field set is FILE_REFERENCE_TYPES = image/file/avatar/video/audio (packages/spec/src/data/field-value.zod.ts)" } ], "teardown": "DELETE /api/v1/packages/com.objectstack.qa.media — or discard the isolated file DB, the cheaper path an isolated boot makes free.", "knownGaps": [ - "Same SDK sharp edge as qa-scratch-authz: `meta.saveItem` drops `?package=` (packages/client/src/index.ts:701-707) — issue step 2 as raw HTTP or the object lands outside the package and the teardown misses it.", - "Unlike qa-scratch-authz this recipe has NOT yet been proven by a live run — it is derived from the same authoring path the proven recipe uses plus the FieldSchema declaration. If step 2 4xxs on the constraint keys, re-read field.zod.ts:876-883 before assuming the recipe rotted; a parse-time strip of accept/maxSize would itself be a finding (the pre-#4001 silent-strip class)." + "Same SDK sharp edge as qa-scratch-authz: `meta.saveItem` drops `?package=` (packages/client/src/index.ts) — issue step 2 as raw HTTP or the object lands outside the package and the teardown misses it.", + "Unlike qa-scratch-authz this recipe has NOT yet been proven by a live run — it is derived from the same authoring path the proven recipe uses plus the FieldSchema declaration. If step 2 4xxs on the constraint keys, re-read field.zod.ts before assuming the recipe rotted; a parse-time strip of accept/maxSize would itself be a finding (the pre-#4001 silent-strip class)." ] } }, @@ -364,7 +364,7 @@ "as the read-only member, POST /api/v1/data/sys_attachment { parent_object, parent_id, file_id, file_name, mime_type, size } against the readable-but-not-editable record and capture the refusal", "as the same member, list that parent's attachments — reading must still work (read inherits parent READ, attach requires parent EDIT: two different gates)", "as a parent editor, POST the same attach payload with a spoofed uploaded_by of another user and read the row back", - "as admin, POST one throwaway qa_nofiles record ({ name: 'qa-nofiles-probe' }) for a real parent_id (qa_nofiles is a scratch OBJECT from the recipe with no standing rows), then PATCH the attachment created in the previous step to re-point it — { parent_object: 'qa_nofiles', parent_id: } — as admin (who can edit both the original parent and the qa_nofiles record it just created, isolating the refusal from ATTACHMENT_PARENT_ACCESS) and capture the refusal. This is the UPDATE half of the #10170 gate (enforceFilesCapability registered on beforeUpdate as well as beforeInsert, packages/plugins/plugin-audit/src/audit-writers.ts:1483-1503): re-read the attachment afterward and confirm parent_object/parent_id are UNCHANGED", + "as admin, POST one throwaway qa_nofiles record ({ name: 'qa-nofiles-probe' }) for a real parent_id (qa_nofiles is a scratch OBJECT from the recipe with no standing rows), then PATCH the attachment created in the previous step to re-point it — { parent_object: 'qa_nofiles', parent_id: } — as admin (who can edit both the original parent and the qa_nofiles record it just created, isolating the refusal from ATTACHMENT_PARENT_ACCESS) and capture the refusal. This is the UPDATE half of the #10170 gate (enforceFilesCapability registered on beforeUpdate as well as beforeInsert, packages/plugins/plugin-audit/src/audit-writers.ts): re-read the attachment afterward and confirm parent_object/parent_id are UNCHANGED", "as a member who is neither the uploader nor a parent editor, DELETE the attachment and capture the refusal", "as the uploader, DELETE their own attachment on a parent they cannot edit — the uploader may always detach", "drive a predicate-less multi-delete from an ENGINE HOLDER — ql.delete('sys_attachment', { multi: true, context }) with no id and no where — and capture the refusal. ⛔ Not over REST: DeleteManyDataRequestSchema requires an ids list (#3897), so the HTTP lane cannot express the shape and its schema rejection must never be scored as this refusal" @@ -416,10 +416,10 @@ "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (clauses 0, 1, 2 and 4: attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS while the same member's LIST still succeeds, uploaded_by is server-stamped over a spoofed value, delete splits uploader-vs-outsider, and FILES_DISABLED) + packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts, which now carries TWO blocks and pins CLAUSE 3 end to end. Block 1 (split fixture — the caller owns one row and may not touch the other): the predicate-less `{ multi: true }` delete is refused with ATTACHMENT_DELETE_DENIED / 403 / the 'Refusing an unscoped multi-delete of attachments' message and removes NOTHING, not even the row it WAS entitled to, so the refusal is authoritative and cannot land as a partial delete; a match-all `where: {}` reaches the same verdict by a DIFFERENT rule and asserts the PER-ROW message instead, which is what keeps the #9797 boundary honest (the whole-operation dispatch is deliberately scoped to a delete with no `where` at all, so `where: {}` stays a real query); and a predicate that MATCHES NOTHING is allowed through, the live half of the #4757 reasoning. Block 2 (the discriminating fixture — the caller uploaded EVERY row, so the per-row gate has nothing to refuse): the predicate-less delete is still refused, which is the only shape that proves 'refused OUTRIGHT' rather than 'every row happened to be denied'; the refusal also fires on an EMPTY table (the zero-match limb — the per-row dispatch is gated on matched rows, so a handler-only fix could never reach it); and both sides are held by the same caller sweeping the same rows with a SCOPED predicate successfully, plus a scoped `where: {}` over the empty table resolving. In both blocks the member is granted the sys_attachment delete bit first and the grant is asserted, because otherwise RBAC refuses ahead of the attachment hook and the file would be green for a reason it is not about. ⭐ Block 2 exists because block 1 provably cannot see this clause: ablating `dispatchUnscopedMultiWrite` from both registrations (rebuilt, dist-preflighted) leaves block 1 5/5 GREEN, while block 2 goes red BY RESOLVING — 'promise resolved \"2\"', which is #9719's measured wipe reproduced. Unit-level companion, also through a wired engine rather than a hand-built context: packages/services/service-storage/src/attachment-access-hooks.test.ts, describe 'unscoped multi-delete (no id, no where) — #4757 through the wired engine (#9719)'. CLAUSE 5 (the UPDATE-verb FILES_DISABLED re-point, new) has a HANDLER/ENGINE-level pin, NOT the wired dogfood/REST lane above: packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts, describe '[#10170] enable.files is asked on the UPDATE verb too' — runs a real ObjectQL (stub driver) rather than the hand-rolled fake in audit-writers.test.ts (whose registerHook ignores the { object } scope, so it cannot observe a registration-scope regression), and pins BOTH dispatch shapes (by-id AND predicate/per-row) refusing 403 FILES_DISABLED on re-point while leaving the row's parent_object unchanged, plus the positive (a re-point onto a files-ENABLED parent still succeeds) and the negative-control (an update that never names parent_object is not re-checked at all). This engine-level pin is why the checklist step above can isolate the refusal to FILES_DISABLED with confidence; it is NOT itself evidence about the wired REST PATCH lane, which is what clause 5's own oracle drives by hand." }, "source": [ "docs/plans/release-15.1-test-plan.md §A12 (attach 需 parent EDIT)", - "packages/services/service-storage/src/attachment-access-hooks.ts (beforeInsert canEdit gate + uploaded_by stamping; beforeDelete/beforeUpdate uploader-or-editor + #4757 unscoped refusal reached via the `dispatchUnscopedMultiWrite` declaration (#9719/#9974), MULTI_WRITE_AUTH_LIMIT fail-closed; :373-410 authorizes an update-verb re-point onto the NEW parent, the access half clause 5 relies on)", + "packages/services/service-storage/src/attachment-access-hooks.ts (beforeInsert canEdit gate + uploaded_by stamping; beforeDelete/beforeUpdate uploader-or-editor + #4757 unscoped refusal reached via the `dispatchUnscopedMultiWrite` declaration (#9719/#9974), MULTI_WRITE_AUTH_LIMIT fail-closed; authorizes an update-verb re-point onto the NEW parent, the access half clause 5 relies on)", "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (items 3, a, f; FILES_DISABLED)", - "packages/plugins/plugin-audit/src/audit-writers.ts:1483-1503 (enforceFilesCapability — the #2727 enable.files gate; [#10170] registered it on beforeUpdate as well as beforeInsert because the flag gates the TARGET object a write makes an attachment point at, not the create/update verb) — the FILES_DISABLED source for both clause 4 (create) and the new clause 5 (update/re-point)", - "packages/spec/src/data/object.zod.ts:281 (enable.files describe string, post-#10733: 'otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike')", + "packages/plugins/plugin-audit/src/audit-writers.ts (enforceFilesCapability — the #2727 enable.files gate; [#10170] registered it on beforeUpdate as well as beforeInsert because the flag gates the TARGET object a write makes an attachment point at, not the create/update verb) — the FILES_DISABLED source for both clause 4 (create) and the new clause 5 (update/re-point)", + "packages/spec/src/data/object.zod.ts (enable.files describe string, post-#10733: 'otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike')", "packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts (the #10170 engine-level pin for the update-verb capability gate — both files and feeds, both dispatch shapes)" ], "history": [ @@ -618,7 +618,7 @@ ] }, "steps": [ - "POST /api/v1/storage/upload/chunked { filename, mimeType, totalSize, chunkSize } and capture { uploadId, fileId } — the init route's required trio is filename/mimeType/`totalSize` (a body spelling `size` is refused 400 INVALID_REQUEST), and `chunkSize` is floored at 5 MiB, so a smaller requested value silently yields a different totalChunks than the caller computed (storage-routes.ts:274-281)", + "POST /api/v1/storage/upload/chunked { filename, mimeType, totalSize, chunkSize } and capture { uploadId, fileId } — the init route's required trio is filename/mimeType/`totalSize` (a body spelling `size` is refused 400 INVALID_REQUEST), and `chunkSize` is floored at 5 MiB, so a smaller requested value silently yields a different totalChunks than the caller computed (storage-routes.ts)", "PUT one chunk, then GET /upload/chunked//progress and record uploadedChunks/percentComplete/status ('in_progress') — progress is the first step of the SDK's resumeUpload", "complete a full session on a second upload and verify its sys_upload_session row reaches status 'completed'", "abandon the first session mid-flight; backdate its expires_at past the 1d TTL (system write) and trigger the lifecycle sweep", @@ -677,14 +677,14 @@ "packages/services/service-storage/src/attachment-lifecycle.ts (createUploadSessionReapGuard — abort-before-reap contract, completed/no-backend confirm branches, veto-on-failure)", "packages/services/service-storage/src/objects/system-upload-session.object.ts (status enum = the variants list; ttl expires_at+1d, retention 7d terminal statuses)", "packages/services/service-storage/src/storage-route-ledger.ts (upload-chunked family)", - "packages/services/service-storage/src/storage-routes.ts:274-281 (POST /upload/chunked init contract: filename/mimeType/totalSize required, else 400 INVALID_REQUEST; chunkSize = Math.max(reqChunkSize ?? 5242880, 5242880), i.e. a 5 MiB floor)", + "packages/services/service-storage/src/storage-routes.ts (POST /upload/chunked init contract: filename/mimeType/totalSize required, else 400 INVALID_REQUEST; chunkSize = Math.max(reqChunkSize ?? 5242880, 5242880), i.e. a 5 MiB floor)", "packages/services/service-storage/src/storage-routes.ts (expireIfPastDeadline / markSessionFailed — the #7667 producers for 'expired' and 'failed'; chunk + complete refuse 410 UPLOAD_SESSION_EXPIRED, progress reports the status)", "packages/spec/src/api/storage.zod.ts (UploadProgressSchema.status — the client-facing declaration the enum must stay in step with)", "docs/plans/release-15.1-test-plan.md §C4 (#2970 item 4)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: chunked-session lifecycle + multipart-abort guard from the reap-guard source, variants pinned to the sys_upload_session status enum; S3-only consequences honestly recorded as a known gap", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-11", "change": "step-text correction from run #7635: the chunked-init step said `size`, but the route destructures `totalSize` and 400s INVALID_REQUEST without it — a runner following the old text could not open a session at all. Step now names totalSize and the 5 MiB chunkSize floor (which silently rewrites totalChunks when a caller asks for less), with storage-routes.ts:274-281 added to source as the grounding. Clause 4's failed/expired enforce-or-remove finding from the same run is deliberately NOT touched here — it is owned by #7667", "ref": "#7671" }, + { "revision": 2, "date": "2026-08-11", "change": "step-text correction from run #7635: the chunked-init step said `size`, but the route destructures `totalSize` and 400s INVALID_REQUEST without it — a runner following the old text could not open a session at all. Step now names totalSize and the 5 MiB chunkSize floor (which silently rewrites totalChunks when a caller asks for less), with storage-routes.ts added to source as the grounding. Clause 4's failed/expired enforce-or-remove finding from the same run is deliberately NOT touched here — it is owned by #7667", "ref": "#7671" }, { "revision": 3, "date": "2026-08-11", "change": "closes the clause-5 finding revision 2 deferred: 'failed' and 'expired' were declared, reaped on, and published by UploadProgressSchema with NO producer, so run #7635's scan could only ever return ['in_progress','completed']. #7667 took the ENFORCE branch of ADR-0049 (removal would have forked the object from the spec's progress contract), so both statuses now have named writers — two steps added to drive them, a new acceptance clause for the 410 UPLOAD_SESSION_EXPIRED refusal, and the transient 'completing' recorded as a knownGap instead of an unreachable-variant FAIL", "ref": "#7667" } ] }, @@ -755,8 +755,8 @@ "docs/plans/release-15.1-test-plan.md §C2 (#3051 + objectui#2585)", "objectui e2e/live/grid-file-upload.spec.ts (the live pin: auto-column, input-not-text, chip, batch payload shape)", "examples/app-showcase/src/data/objects/invoice.object.ts (showcase_invoice_line.receipt = Field.file, objectui#2360 note)", - "docs/adr/0104-field-runtime-value-shape-contract.md:211-224 — D3 'File-as-reference: field values point into sys_file': the STORED form is an opaque fileId string (the inline {url, name, size} blob is retired from the write path); the {id, name, size, mimeType, url} FileValueSchema is the EXPANDED form produced at read time, whose url is 'derived, never stored'; field-referenced files get parent-derived read checks", - "packages/services/service-storage/src/storage-routes.ts:250-252 — /upload/complete returns fileId, documented as 'the opaque sys_file id — the value a file field stores as a reference (ADR-0104 D3)'", + "docs/adr/0104-field-runtime-value-shape-contract.md — D3 'File-as-reference: field values point into sys_file': the STORED form is an opaque fileId string (the inline {url, name, size} blob is retired from the write path); the {id, name, size, mimeType, url} FileValueSchema is the EXPANDED form produced at read time, whose url is 'derived, never stored'; field-referenced files get parent-derived read checks", + "packages/services/service-storage/src/storage-routes.ts — /upload/complete returns fileId, documented as 'the opaque sys_file id — the value a file field stores as a reference (ADR-0104 D3)'", "ADR-0104 D3's parent-derived read gating for field-owned files is exercised by attachments-storage.download-authz-both-sides (which carries the non-entitled personas). Cross-reference, do not duplicate", "ADR-0059 — the FORM-side Confirm-disabled-while-uploading guard is records-forms.upload-guard-blocks-confirm; this item owns the storage/persistence side. Cross-reference, do not duplicate" ], @@ -784,17 +784,17 @@ "app": "showcase", "requires": [ "the qa_media scratch object with field-level accept/maxSize (see provisioning) — no stock showcase object declares either", - "the storage service live at the default base — presign/complete is how the probe sys_file rows are minted, and their mime_type/size are the DECLARED values the check reads (storage-routes.ts:241-243,255)", - "a system-write channel to CLEAR mime_type and size on one committed sys_file row for the missing-metadata clause — the presign door refuses a body without mimeType/size (400 INVALID_REQUEST, storage-routes.ts:242-243), so a metadata-less row cannot be minted through the API; without the system write that one clause is blocked(fixture)" + "the storage service live at the default base — presign/complete is how the probe sys_file rows are minted, and their mime_type/size are the DECLARED values the check reads (storage-routes.ts,255)", + "a system-write channel to CLEAR mime_type and size on one committed sys_file row for the missing-metadata clause — the presign door refuses a body without mimeType/size (400 INVALID_REQUEST, storage-routes.ts), so a metadata-less row cannot be minted through the API; without the system write that one clause is blocked(fixture)" ], "provisioning": { "use": "qa-media-constraints", "why": "every clause of this item runs against qa_media.poster (MIME accept + maxSize) and qa_media.doc (extension-only accept). Without the recipe the ENTIRE item is blocked(fixture): the showcase's only accept/maxSize declarations sit on action params, which never reach this seam." }, "knownGaps": [ - "FIXTURE GAP (why the recipe exists): the showcase declares accept/maxSize only on ACTION PARAMS (examples/app-showcase/src/ui/actions/index.ts:334,337) — that lane feeds the dialog widget and the ADR-0059 Confirm-while-uploading guard pinned by records-forms.upload-guard-blocks-confirm, and never reaches assertFileConstraints, which fires on OBJECT-FIELD references at record write. No showcase object field carries either key, so on stock seeds this item cannot run at all. Landing one constrained field in the showcase seeds would retire the qa-media-constraints recipe.", - "CONTENT-SNIFFING BOUNDARY (record so runs do not score it as a leak): enforcement is DECLARATION-based, never content-based. sys_file.mime_type is taken verbatim from the client's own request body on both upload doors — presigned (storage-routes.ts:241-243 destructure, :255 persisted, :267 even echoed back as the upload content-type header) and chunked (:342-344, :359) — and nothing on the upload path sniffs magic bytes (no file-type/sniffing code exists in service-storage; measured by search, only fs.readdir withFileTypes matches). A caller may therefore upload arbitrary bytes while declaring image/png and pass every accept test. That is the boundary of what the platform claims; a run demonstrating it records a documented-boundary observation, never a FAIL of this item.", - "WIRE-ENVELOPE (per source, unmeasured — the run must record what it sees): FileConstraintError declares `code: 'ERR_FILE_CONSTRAINT'` (registered, packages/spec/src/api/error-code-ledger.zod.ts:320) but NO `status`/`statusCode` (file-reference-lifecycle.ts:168-173), and rest's classifyDataError has no ERR_FILE_CONSTRAINT branch — so the declared-status passthrough never fires and the refusal should exit the /api/v1/data door through the sanitized 500 INTERNAL_ERROR terminal (packages/rest/src/error-response.ts UNCLASSIFIED_FAULT), the field/accept prose reaching the server LOG rather than the body. Its sibling FileFieldBulkWriteError documents `status: 400` as exactly what prevents that promotion (file-reference-lifecycle.ts:181-191), and #7525 fixed the same shape for statusCode-declaring hooks. Consequence for scoring: the enforcement clauses pass on the NO-ROW oracle regardless of envelope; a measured sanitized 500 is a wire-contract finding to EXTRACT as its own card, and a measured 4xx carrying ERR_FILE_CONSTRAINT means the product improved — revise clause 2." + "FIXTURE GAP (why the recipe exists): the showcase declares accept/maxSize only on ACTION PARAMS (examples/app-showcase/src/ui/actions/index.ts,337) — that lane feeds the dialog widget and the ADR-0059 Confirm-while-uploading guard pinned by records-forms.upload-guard-blocks-confirm, and never reaches assertFileConstraints, which fires on OBJECT-FIELD references at record write. No showcase object field carries either key, so on stock seeds this item cannot run at all. Landing one constrained field in the showcase seeds would retire the qa-media-constraints recipe.", + "CONTENT-SNIFFING BOUNDARY (record so runs do not score it as a leak): enforcement is DECLARATION-based, never content-based. sys_file.mime_type is taken verbatim from the client's own request body on both upload doors — presigned (storage-routes.ts destructure, persisted, even echoed back as the upload content-type header) and chunked — and nothing on the upload path sniffs magic bytes (no file-type/sniffing code exists in service-storage; measured by search, only fs.readdir withFileTypes matches). A caller may therefore upload arbitrary bytes while declaring image/png and pass every accept test. That is the boundary of what the platform claims; a run demonstrating it records a documented-boundary observation, never a FAIL of this item.", + "WIRE-ENVELOPE (per source, unmeasured — the run must record what it sees): FileConstraintError declares `code: 'ERR_FILE_CONSTRAINT'` (registered, packages/spec/src/api/error-code-ledger.zod.ts) but NO `status`/`statusCode` (file-reference-lifecycle.ts), and rest's classifyDataError has no ERR_FILE_CONSTRAINT branch — so the declared-status passthrough never fires and the refusal should exit the /api/v1/data door through the sanitized 500 INTERNAL_ERROR terminal (packages/rest/src/error-response.ts UNCLASSIFIED_FAULT), the field/accept prose reaching the server LOG rather than the body. Its sibling FileFieldBulkWriteError documents `status: 400` as exactly what prevents that promotion (file-reference-lifecycle.ts), and #7525 fixed the same shape for statusCode-declaring hooks. Consequence for scoring: the enforcement clauses pass on the NO-ROW oracle regardless of envelope; a measured sanitized 500 is a wire-contract finding to EXTRACT as its own card, and a measured 4xx carrying ERR_FILE_CONSTRAINT means the product improved — revise clause 2." ] }, "steps": [ @@ -803,21 +803,21 @@ "POST /api/v1/data/qa_media { name: 'accept-probe', poster: } and capture the refusal; list qa_media where name='accept-probe' → must be empty; read sys_file A in stored form → ref_id must still be null (the throw lands before the claim)", "POST /api/v1/data/qa_media { name: 'size-probe', poster: } and capture the refusal + the empty re-list", "POST /api/v1/data/qa_media { name: 'control', poster: } → 2xx; read sys_file C: ref_object='qa_media', ref_field='poster', ref_id=", - "update path: PUT/PATCH the 'control' record swapping poster to → capture the refusal; re-read the record → poster unchanged (the check also rides beforeUpdate, file-reference-lifecycle.ts:676)", + "update path: PUT/PATCH the 'control' record swapping poster to → capture the refusal; re-read the record → poster unchanged (the check also rides beforeUpdate, file-reference-lifecycle.ts)", "capture the server log around each refusal: the FileConstraintError text names the field and the accept list ('not permitted by the accept list declared for...') or the byte counts ('exceeds the maximum size declared for...'); record the WIRE status + code each refusal actually surfaced with", - "documented hole 1 (missing metadata): system-write one committed sys_file clearing mime_type AND size to null, then POST it into poster → the write SUCCEEDS (file-reference-lifecycle.ts:255-257 — 'missing metadata is not evidence of a violation'; the maxSize guard at :267 and the mime read at :277 both require the value to be present)", - "documented hole 2 (extension-only accept vs dotless name): POST { doc: } → SUCCEEDS — accept ['.pdf'] against a name with no dot leaves testable empty and the check exits before judging (:285-289); contrast POST { doc: } → REFUSED ('report.txt' HAS an extension, so the .pdf entry is testable and mismatches, :238) — the contrast proves the hole is the dotless case, not a dead extension arm", + "documented hole 1 (missing metadata): system-write one committed sys_file clearing mime_type AND size to null, then POST it into poster → the write SUCCEEDS (file-reference-lifecycle.ts — 'missing metadata is not evidence of a violation'; the maxSize guard and the mime read both require the value to be present)", + "documented hole 2 (extension-only accept vs dotless name): POST { doc: } → SUCCEEDS — accept ['.pdf'] against a name with no dot leaves testable empty and the check exits before judging; contrast POST { doc: } → REFUSED ('report.txt' HAS an extension, so the.pdf entry is testable and mismatches) — the contrast proves the hole is the dotless case, not a dead extension arm", "boundary demonstration (optional): upload PDF bytes declared as image/png within maxSize → POST into poster → passes; record as documented-boundary, per knownGaps" ], "acceptance": [ { - "clause": "a direct API write referencing a committed sys_file whose mime_type violates the field's declared accept is REFUSED and persists nothing: no qa_media row, and the sys_file row stays unclaimed (ref_id null) — the widget check is 'a convenience rather than a control — any caller talking to the API directly bypasses it' (file-reference-lifecycle.ts:247-249), and this server re-check is the control", + "clause": "a direct API write referencing a committed sys_file whose mime_type violates the field's declared accept is REFUSED and persists nothing: no qa_media row, and the sys_file row stays unclaimed (ref_id null) — the widget check is 'a convenience rather than a control — any caller talking to the API directly bypasses it' (file-reference-lifecycle.ts), and this server re-check is the control", "oracle": "api", "verify": "the POST answers non-2xx; a scoped re-list of qa_media returns zero rows; the stored-form sys_file read shows ref_id still null", "evidence": "the refusal body + the empty list + the sys_file read" }, { - "clause": "a reference whose sys_file size exceeds the field's declared maxSize is refused identically, no row written (assertFileConstraints, file-reference-lifecycle.ts:267-274)", + "clause": "a reference whose sys_file size exceeds the field's declared maxSize is refused identically, no row written (assertFileConstraints, file-reference-lifecycle.ts)", "oracle": "api", "verify": "same triple as clause 0 for the oversized probe", "evidence": "the refusal body + the empty list" @@ -829,7 +829,7 @@ "evidence": "log excerpt + the measured wire status/code" }, { - "clause": "the UPDATE path is equally gated: swapping a compliant record's poster to the violating fileId is refused and the stored value is unchanged (applyCopyOnClaim runs in beforeUpdate too, file-reference-lifecycle.ts:676)", + "clause": "the UPDATE path is equally gated: swapping a compliant record's poster to the violating fileId is refused and the stored value is unchanged (applyCopyOnClaim runs in beforeUpdate too, file-reference-lifecycle.ts)", "oracle": "api", "verify": "the update answers non-2xx and a re-read shows the original poster value", "evidence": "the refusal + the re-read" @@ -841,33 +841,33 @@ "evidence": "the 2xx + the stamped row read" }, { - "clause": "DOCUMENTED BOUNDARY, scored as a pass: a sys_file with NO mime_type cannot fail an accept test and one with NO size cannot fail maxSize — the metadata-less reference is ACCEPTED by design ('missing metadata is not evidence of a violation', file-reference-lifecycle.ts:255-257; guards at :267 and :277/:285-288 each require the datum to be present). A run tempted to score this as an enforcement leak is pointed here instead", + "clause": "DOCUMENTED BOUNDARY, scored as a pass: a sys_file with NO mime_type cannot fail an accept test and one with NO size cannot fail maxSize — the metadata-less reference is ACCEPTED by design ('missing metadata is not evidence of a violation', file-reference-lifecycle.ts; guards each require the datum to be present). A run tempted to score this as an enforcement leak is pointed here instead", "oracle": "api", "verify": "after the system write clears mime_type and size, the same POST that clause 0 saw refused now answers 2xx", "evidence": "the cleared sys_file read + the 2xx" }, { - "clause": "DOCUMENTED BOUNDARY, scored as a pass, with its live contrast: an accept list of ONLY extension entries judged against a filename with no dot yields testable.length === 0 and the check exits without judging (file-reference-lifecycle.ts:285-289) — the dotless write into doc (accept ['.pdf']) SUCCEEDS by design, while the dotted mismatch ('report.txt') is refused, proving the extension arm itself is alive and only the dotless case is the declared hole", + "clause": "DOCUMENTED BOUNDARY, scored as a pass, with its live contrast: an accept list of ONLY extension entries judged against a filename with no dot yields testable.length === 0 and the check exits without judging (file-reference-lifecycle.ts) — the dotless write into doc (accept ['.pdf']) SUCCEEDS by design, while the dotted mismatch ('report.txt') is refused, proving the extension arm itself is alive and only the dotless case is the declared hole", "oracle": "api", "verify": "POST with the dotless-name file answers 2xx; POST with the .txt-named file is refused with no row", "evidence": "both responses + the one empty re-list" } ], "negative": [ - "a qa_media row created holding the violating reference is THE fail this item exists for — it means the only guard is the client widget, the exact 'declared but not enforced' state ADR-0104 removes (file-reference-lifecycle.ts:251-253)", + "a qa_media row created holding the violating reference is THE fail this item exists for — it means the only guard is the client widget, the exact 'declared but not enforced' state ADR-0104 removes (file-reference-lifecycle.ts)", "a refusal that still claimed the file (sys_file ref_id stamped despite the non-2xx) is a FAIL — the rejection must be authoritative, not cosmetic (same rule as attach-requires-parent-edit)", "⛔ NOT failures: the missing-metadata pass (clause 5) and the dotless-extension pass (clause 6) — both are the source's own documented boundaries; and arbitrary BYTES passing under a compliant declared mimeType is the declaration-based content boundary in knownGaps, not a bypass", - "⛔ NOT a violation probe: a random/unknown id token in the field passes untouched BY DESIGN — an id matching no sys_file row is treated as an external/legacy value and skipped before any constraint is read (file-reference-lifecycle.ts:421-423); probe with a REAL committed sys_file or the run measures nothing" + "⛔ NOT a violation probe: a random/unknown id token in the field passes untouched BY DESIGN — an id matching no sys_file row is treated as an external/legacy value and skipped before any constraint is read (file-reference-lifecycle.ts); probe with a REAL committed sys_file or the run measures nothing" ], "traps": ["absence-inference", "wrong-panel"], - "automated": { "kind": "unit", "ref": "packages/services/service-storage/src/file-reference-lifecycle.test.ts ('accept / maxSize enforcement' describe, :906-1006) — HANDLER-DIRECT: drives the hook through a fake engine, covering the semantics of clauses 0/1/4/5/6 (rejection, per-entry vocabulary, missing-metadata pass, no-constraints pass) at the unit level. It is NOT evidence about the wired REST lane or the wire envelope (clause 2), and per the attach-requires-parent-edit clause-3 lesson a handler-direct green can coexist with different wired behaviour — the dogfood lane has NO pin for this seam, which is precisely why this item exists." }, + "automated": { "kind": "unit", "ref": "packages/services/service-storage/src/file-reference-lifecycle.test.ts ('accept / maxSize enforcement' describe) — HANDLER-DIRECT: drives the hook through a fake engine, covering the semantics of clauses 0/1/4/5/6 (rejection, per-entry vocabulary, missing-metadata pass, no-constraints pass) at the unit level. It is NOT evidence about the wired REST lane or the wire envelope (clause 2), and per the attach-requires-parent-edit clause-3 lesson a handler-direct green can coexist with different wired behaviour — the dogfood lane has NO pin for this seam, which is precisely why this item exists." }, "source": [ - "packages/services/service-storage/src/file-reference-lifecycle.ts:243-257 (the rationale: the widget check is a convenience, the server re-check is the control; missing metadata is not evidence of a violation), :259-298 (assertFileConstraints — maxSize guard :267-274, testable filter + early return :276-289, accept mismatch throw :291-296), :229-241 (matchesAcceptEntry — exact MIME / type\\/* wildcard / .ext-against-NAME vocabulary), :421-427 (the check rides copy-on-claim in the before hooks — beforeInsert :622, beforeUpdate :676 — and unknown ids are skipped at :423)", - "packages/spec/src/data/field.zod.ts:876-883 (accept/maxSize declared on FieldSchema, 'Offered to the file picker AND enforced on write') + packages/spec/liveness/field.json (both keys `live` with this enforcement as evidence)", - "packages/services/service-storage/src/storage-routes.ts:241-243,:255,:267 (presigned) and :342-344,:359 (chunked) — mime_type/size persisted VERBATIM from the client body: the declaration-based boundary in knownGaps", - "packages/spec/src/api/error-code-ledger.zod.ts:320 (ERR_FILE_CONSTRAINT registered) vs file-reference-lifecycle.ts:168-173 (no status declared) vs packages/rest/src/error-response.ts classifyDataError (no branch, declared-status passthrough skipped, UNCLASSIFIED_FAULT terminal) — the clause-2 wire analysis; file-reference-lifecycle.ts:181-191 (FileFieldBulkWriteError's `status: 400` note is the in-module precedent for what a 4xx exit requires)", + "packages/services/service-storage/src/file-reference-lifecycle.ts (the rationale: the widget check is a convenience, the server re-check is the control; missing metadata is not evidence of a violation), (assertFileConstraints — maxSize guard, testable filter + early return, accept mismatch throw), (matchesAcceptEntry — exact MIME / type\\/* wildcard /.ext-against-NAME vocabulary), (the check rides copy-on-claim in the before hooks — beforeInsert, beforeUpdate — and unknown ids are skipped)", + "packages/spec/src/data/field.zod.ts (accept/maxSize declared on FieldSchema, 'Offered to the file picker AND enforced on write') + packages/spec/liveness/field.json (both keys `live` with this enforcement as evidence)", + "packages/services/service-storage/src/storage-routes.ts (presigned) and (chunked) — mime_type/size persisted VERBATIM from the client body: the declaration-based boundary in knownGaps", + "packages/spec/src/api/error-code-ledger.zod.ts (ERR_FILE_CONSTRAINT registered) vs file-reference-lifecycle.ts (no status declared) vs packages/rest/src/error-response.ts classifyDataError (no branch, declared-status passthrough skipped, UNCLASSIFIED_FAULT terminal) — the clause-2 wire analysis; file-reference-lifecycle.ts (FileFieldBulkWriteError's `status: 400` note is the in-module precedent for what a 4xx exit requires)", "packages/services/service-storage/CHANGELOG.md 17.0.0-rc.0 (changeset fe67e34, ADR-0104 D3 wave 2 PR-5a) — grounds since: v17", - "records-forms.upload-guard-blocks-confirm owns the CLIENT half (ADR-0059 dialog guard over ACTION-PARAM accept/maxSize, examples/app-showcase/src/ui/actions/index.ts:334,337). Cross-reference, do not duplicate — that item never touches this record-write seam" + "records-forms.upload-guard-blocks-confirm owns the CLIENT half (ADR-0059 dialog guard over ACTION-PARAM accept/maxSize, examples/app-showcase/src/ui/actions/index.ts,337). Cross-reference, do not duplicate — that item never touches this record-write seam" ], "history": [ { "revision": 1, "date": "2026-08-20", "change": "new item from the scoped scan-functionality sweep (扫描功能): the server-side re-enforcement of field accept/maxSize had NO coverage on either lane — the only pinned guard is the client dialog's (records-forms.upload-guard-blocks-confirm), which the enforcement source itself calls 'a convenience rather than a control'. Authored with the qa-media-constraints recipe (no stock showcase field declares either key), the two source-documented holes (missing metadata, dotless-name vs extension-only accept) as documented-boundary passes, the declaration-based content boundary recorded so runs do not score sniffing absence as a leak, and the per-source wire-envelope analysis (ERR_FILE_CONSTRAINT ledgered but status-less → sanitized 500 expected) carried as a knownGap for the run to measure rather than rediscover", "ref": "claude/new-session-0pv25p" } diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 90a6baef72..1e6af0be9c 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -23,7 +23,7 @@ "enableOnInstall": true }, "expect": "2xx with the installed package echoed back. The id is deliberately distinct from the other QA recipes so this one installs and tears down independently. A re-run against a live DB 409s on the duplicate id — send `overwrite: true` deliberately, never by reflex.", - "source": "POST /api/v1/packages is the dispatcher install route (packages/rest/src/rest-route-ledger.ts:312 note; client `packages.install`); body shape { manifest, enableOnInstall?, overwrite? } pinned in packages/client/src/client.test.ts:2144-2166" + "source": "POST /api/v1/packages is the dispatcher install route (packages/rest/src/rest-route-ledger.ts note; client `packages.install`); body shape { manifest, enableOnInstall?, overwrite? } pinned in packages/client/src/client.test.ts" }, { "step": 2, @@ -36,8 +36,8 @@ "nodes": [{ "id": "n1", "type": "end", "label": "End" }], "edges": [] }, - "expect": "2xx. The definition is deliberately AUTHORABLE-but-unexecutable: FlowSchema declares `nodes: z.array(FlowNodeSchema)` with no `.min(1)` and no refinement requiring a `start` member (packages/spec/src/automation/flow.zod.ts:658), and nothing rejects a start-less flow at publish, so this lands. It is the engine that refuses it at DISPATCH — `flow.nodes.find(n => n.type === 'start')` misses and execute() returns { success:false, code:'FLOW_NO_START_NODE' } (packages/services/service-automation/src/engine.ts:3212-3220). ⚠️ If a publish-time structural check lands later this step will start 4xx-ing — that is a FINDING about where the refusal moved, not a rotted recipe.", - "source": "flow is NOT in the org-overlay-allowed set (RUNNER.md's overlay fact — view/dashboard/report/translation/email_template only), so the `?package=` route is required exactly as qa-scratch-authz uses it for objects; `?package=` is read as query.package and threaded to saveMetaItem as packageId (packages/runtime/src/domains/meta.ts:262,319)" + "expect": "2xx. The definition is deliberately AUTHORABLE-but-unexecutable: FlowSchema declares `nodes: z.array(FlowNodeSchema)` with no `.min(1)` and no refinement requiring a `start` member (packages/spec/src/automation/flow.zod.ts), and nothing rejects a start-less flow at publish, so this lands. It is the engine that refuses it at DISPATCH — `flow.nodes.find(n => n.type === 'start')` misses and execute() returns { success:false, code:'FLOW_NO_START_NODE' } (packages/services/service-automation/src/engine.ts). ⚠️ If a publish-time structural check lands later this step will start 4xx-ing — that is a FINDING about where the refusal moved, not a rotted recipe.", + "source": "flow is NOT in the org-overlay-allowed set (RUNNER.md's overlay fact — view/dashboard/report/translation/email_template only), so the `?package=` route is required exactly as qa-scratch-authz uses it for objects; `?package=` is read as query.package and threaded to saveMetaItem as packageId (packages/runtime/src/domains/meta.ts,319)" }, { "step": 3, @@ -63,9 +63,9 @@ "source": "packages/runtime/src/domains/automation.ts (POST /:name/toggle → engine.toggleFlow); automation.flow-toggle-kill-switch names showcase_urgent_task_alert as the only type:'record_change' flow" } ], - "teardown": "POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": true} to undo step 4, then DELETE /api/v1/packages/com.objectstack.qa.automation (client `packages.uninstall`, ledgered at rest-route-ledger.ts:317) — or discard the isolated file DB, the cheaper path an isolated boot makes free. The toggle is NOT covered by discarding the DB if you reused a boot, so undo it explicitly.", + "teardown": "POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": true} to undo step 4, then DELETE /api/v1/packages/com.objectstack.qa.automation (client `packages.uninstall`, ledgered at rest-route-ledger.ts) — or discard the isolated file DB, the cheaper path an isolated boot makes free. The toggle is NOT covered by discarding the DB if you reused a boot, so undo it explicitly.", "knownGaps": [ - "Same SDK sharp edge as qa-scratch-authz: `meta.saveItem(type, name, item)` does NOT send `?package=` (packages/client/src/index.ts:701-707) — issue steps 2 and 3 as raw HTTP with the query string appended, or the flows land outside the package and the teardown misses them.", + "Same SDK sharp edge as qa-scratch-authz: `meta.saveItem(type, name, item)` does NOT send `?package=` (packages/client/src/index.ts) — issue steps 2 and 3 as raw HTTP with the query string appended, or the flows land outside the package and the teardown misses them.", "This recipe has NOT been proven by a live run. If step 2 or 3 4xxs on the flow body, re-read the cited schema line before assuming the recipe rotted — a publish-time refusal of a start-less flow would itself be a finding (it would mean the 422 row has moved from dispatch time to author time, which changes what the item is asserting)." ] } @@ -1141,7 +1141,7 @@ ], "source": [ "packages/runtime/src/route-ledger.ts (POST /automation/:name/toggle → automation.toggle; GET /automation/_status → automation.getRuntimeStatus)", - "packages/services/service-automation/src/engine.ts:2978-3043 (toggleFlow — writes the sys_metadata_activation row FIRST, then updates the flowLedgerDisabled projection and activateFlowTrigger/deactivateFlowTrigger; the process-local flowEnabled map this item originally cited is RETIRED by ADR-0126 §7.2) + :1344-1382 (flowLedgerDisabled is a projection of the ledger, not the old map under a new name) + getFlowRuntimeStates enabled/bound", + "packages/services/service-automation/src/engine.ts (toggleFlow — writes the sys_metadata_activation row FIRST, then updates the flowLedgerDisabled projection and activateFlowTrigger/deactivateFlowTrigger; the process-local flowEnabled map this item originally cited is RETIRED by ADR-0126 §7.2) + (flowLedgerDisabled is a projection of the ledger, not the old map under a new name) + getFlowRuntimeStates enabled/bound", "docs/adr/0126-packaged-metadata-customization-model.md §7.2 (the durable ledger row replaces the process-local flowEnabled map as the sanctioned off-switch)", "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow — the record_change flow, #3427)", "automation.packaged-flow-disable-durable owns the halves this item deliberately does not: the durable row's shape, restart survival, and the ledger-message oracle; automation.packaged-flow-subflow-disable-refusal owns the §7.3 disable guard (showcase_urgent_task_alert has no packaged subflow callers, so this item's toggles are never refused by it)" @@ -1156,7 +1156,7 @@ { "revision": 2, "date": "2026-08-26", - "change": "re-sourced, semantics untouched: the source cited 'toggleFlow → flowEnabled', and that process-local map is retired by ADR-0126 §7.2 (the #10243 cross-tenant leak's mechanism) — toggleFlow now writes the sys_metadata_activation row first and the engine projects the ledger via flowLedgerDisabled (engine.ts:1344-1382, :2978-3043). Every clause here still holds and still means the same thing (single-boot kill switch: OFF unbinds, /_status mirrors, ON restores); the new durable/ledger-row/restart halves are NOT folded in — they are automation.packaged-flow-disable-durable, and the §7.3 subflow-disable guard is automation.packaged-flow-subflow-disable-refusal, both cross-referenced from source", + "change": "re-sourced, semantics untouched: the source cited 'toggleFlow → flowEnabled', and that process-local map is retired by ADR-0126 §7.2 (the #10243 cross-tenant leak's mechanism) — toggleFlow now writes the sys_metadata_activation row first and the engine projects the ledger via flowLedgerDisabled (engine.ts). Every clause here still holds and still means the same thing (single-boot kill switch: OFF unbinds, /_status mirrors, ON restores); the new durable/ledger-row/restart halves are NOT folded in — they are automation.packaged-flow-disable-durable, and the §7.3 subflow-disable guard is automation.packaged-flow-subflow-disable-refusal, both cross-referenced from source", "ref": "#12438" } ] @@ -1268,7 +1268,7 @@ "packages/runtime/src/flow-dispatch-status.ts — the ONE definition of the trigger-side table (FlowRefusalCode, classifyFlowRefusal, isPausedRun, FLOW_NOT_FOUND_STATUS) and the note on why it is a module rather than a route mapper", "packages/runtime/src/domains/automation.ts — both trigger routes and the resume route's code→status arms", "packages/runtime/src/action-execution.ts and packages/runtime/src/endpoint-executor.ts — the other two doors reading the same table", - "packages/services/service-automation/src/engine.ts:3212-3220 — the FLOW_NO_START_NODE exit; the disabled-flow exit stamps FLOW_DISABLED, and neither carries `status`, which is what lets a transport tell a never-dispatched exit from a run that dispatched and failed", + "packages/services/service-automation/src/engine.ts — the FLOW_NO_START_NODE exit; the disabled-flow exit stamps FLOW_DISABLED, and neither carries `status`, which is what lets a transport tell a never-dispatched exit from a run that dispatched and failed", "packages/spec/src/contracts/automation-service.ts — AutomationResult.code, the closed union the two trigger-time refusals were added to", "packages/spec/src/api/error-code-ledger.zod.ts — ADR-0112 registration of FLOW_DISABLED / FLOW_NO_START_NODE / FLOW_FAILED to @objectstack/runtime", "packages/client/CHANGELOG.md 17.1.0 (bc6434b resume, 9aa8890 disabled/no-start-node, 48032c9 ran-and-failed) — the three BREAKING changesets whose own status table this matrix is derived from", @@ -1278,7 +1278,7 @@ { "revision": 1, "date": "2026-08-21", - "change": "new — the automation area asserted flow BEHAVIOUR but never the status a caller observes, so a regression reverting any of 17.1.0's three BREAKING status changes left the whole area green (found by the 17.1.0 post-release sweep, #10225). The matrix is derived from the three changesets' own status tables and reconciled against the single runtime definition in flow-dispatch-status.ts, which is wider than the changesets: the same table is now read by /actions, the MCP run_action bridge and declared endpoints, and it carries a sixth NON-TERMINAL row (PAUSED, #9510) the changesets do not tabulate. The 422 arm was expected to be a knownGap; it is not — FlowSchema requires no start node (flow.zod.ts:658, no .min(1), no refinement) and nothing refuses a start-less flow at publish, so the row is authorable through a scratch package and is provisioned by qa-flow-status-doors instead of waived", + "change": "new — the automation area asserted flow BEHAVIOUR but never the status a caller observes, so a regression reverting any of 17.1.0's three BREAKING status changes left the whole area green (found by the 17.1.0 post-release sweep, #10225). The matrix is derived from the three changesets' own status tables and reconciled against the single runtime definition in flow-dispatch-status.ts, which is wider than the changesets: the same table is now read by /actions, the MCP run_action bridge and declared endpoints, and it carries a sixth NON-TERMINAL row (PAUSED, #9510) the changesets do not tabulate. The 422 arm was expected to be a knownGap; it is not — FlowSchema requires no start node (flow.zod.ts, no.min(1), no refinement) and nothing refuses a start-less flow at publish, so the row is authorable through a scratch package and is provisioned by qa-flow-status-doors instead of waived", "ref": "#10236" } ] @@ -1298,7 +1298,7 @@ "app": "showcase", "requires": [ "a FILE-backed database — restart survival is structurally unreachable on the in-memory store; record the db path in the run env (the same requirement automation.durable-suspend-restart carries, and its cold-restart recipe — stop the process entirely, boot a second server over the SAME database file — is the one to reuse here)", - "showcase_urgent_task_alert (examples/app-showcase/src/automation/flows/index.ts UrgentTaskAlertFlow) — a PACKAGED record_change flow (package com.example.showcase, examples/app-showcase/objectstack.config.ts:80) with NO packaged subflow callers, so its disable is never refused by the §7.3 guard this item does not test", + "showcase_urgent_task_alert (examples/app-showcase/src/automation/flows/index.ts UrgentTaskAlertFlow) — a PACKAGED record_change flow (package com.example.showcase, examples/app-showcase/objectstack.config.ts) with NO packaged subflow callers, so its disable is never refused by the §7.3 guard this item does not test", "⚠️ export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL standing fact) — the showcase's self-pinging connectors otherwise fail every unrelated flow run in the boot" ] }, @@ -1306,7 +1306,7 @@ "boot showcase isolated against a file DB (dogfood §0); sign in as the dev admin", "baseline: POST /api/v1/data/showcase_task with priority='urgent' — one new run for showcase_urgent_task_alert (the single-boot toggle semantics are automation.flow-toggle-kill-switch's; this item takes only enough baseline to anchor the restart contrast)", "toggle OFF: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false}", - "read the ledger row over the data API: GET /api/v1/data/sys_metadata_activation (reads are open — apiMethods ['get','list'], sys-metadata-activation.object.ts:157) and locate the row where metadata_type='flow' AND name='showcase_urgent_task_alert' — record active, package_id, organization_id, and the row id", + "read the ledger row over the data API: GET /api/v1/data/sys_metadata_activation (reads are open — apiMethods ['get','list'], sys-metadata-activation.object.ts) and locate the row where metadata_type='flow' AND name='showcase_urgent_task_alert' — record active, package_id, organization_id, and the row id", "probe the refusal MESSAGE: POST /api/v1/automation/showcase_urgent_task_alert/trigger — record the full error body (status, code, message)", "cold restart: stop the server process entirely; boot a second server over the SAME database file; read the boot log for the automation plugin's activation-ledger line", "post-restart: GET /api/v1/automation/_status — record enabled/bound for showcase_urgent_task_alert", @@ -1318,19 +1318,19 @@ { "clause": "the toggle writes the durable row, not (only) process state: after {enabled:false} a sys_metadata_activation row exists with metadata_type='flow', name='showcase_urgent_task_alert', package_id='com.example.showcase', active=false, and organization_id NULL (install-level — §5 reserves the column, nothing on this line writes it)", "oracle": "api", - "verify": "the step-4 data-API read returns exactly one such row (unique per (metadata_type, name) via the NULL-collapsed 'organization' index). toggleFlow writes this row FIRST and only then updates the in-process projection (engine.ts:3010-3032, core/src/utils/metadata-activation-store.ts:221-250) — so a 2xx toggle with no row is a FAIL even if the same-boot kill switch works", + "verify": "the step-4 data-API read returns exactly one such row (unique per (metadata_type, name) via the NULL-collapsed 'organization' index). toggleFlow writes this row FIRST and only then updates the in-process projection (engine.ts, core/src/utils/metadata-activation-store.ts) — so a 2xx toggle with no row is a FAIL even if the same-boot kill switch works", "evidence": "the row read (all five columns) + the toggle response" }, { "clause": "the FLOW_DISABLED refusal message names the LEDGER: triggering the disabled flow answers 409 FLOW_DISABLED whose message carries the sys_metadata_activation / activation-ledger phrasing and the two remedies (re-enable, or clone under a new name)", "oracle": "api", - "verify": "the step-5 error message contains 'packaged-metadata activation ledger (sys_metadata_activation' (engine.ts describeDisabledFlow, :2888-2904). ⛔ The CODE alone is NOT the oracle: ADR-0126 §7.2 deliberately REUSES FLOW_DISABLED for the ledger dimension (no new ADR-0112 entry), and a status-disabled flow answers the same 409 FLOW_DISABLED with the old bare \"Flow '' is disabled\" — the ledger-vs-status distinction rides the MESSAGE, so a verdict keyed on code alone cannot tell this item's dimension from the authoring one", + "verify": "the step-5 error message contains 'packaged-metadata activation ledger (sys_metadata_activation' (engine.ts describeDisabledFlow). ⛔ The CODE alone is NOT the oracle: ADR-0126 §7.2 deliberately REUSES FLOW_DISABLED for the ledger dimension (no new ADR-0112 entry), and a status-disabled flow answers the same 409 FLOW_DISABLED with the old bare \"Flow '' is disabled\" — the ledger-vs-status distinction rides the MESSAGE, so a verdict keyed on code alone cannot tell this item's dimension from the authoring one", "evidence": "the full 409 body with the message quoted" }, { "clause": "the disable survives a literal cold restart with the trigger left UNBOUND: on the new process /_status reports enabled=false AND bound=false for the flow, and the boot log carries the activation-ledger line naming it", "oracle": "api", - "verify": "post-restart GET /api/v1/automation/_status shows enabled:false, bound:false — the boot flow pull re-registers and re-arms every flow, and registerFlow deliberately does NOT re-arm a ledger-disabled one (engine.ts:2706-2714); hydrateFlowActivations then runs AFTER the pull (service-automation/src/plugin.ts:986-1005) and disarms from the ledger (engine.ts:2851-2871). Corroborate with the log line '[Automation] Activation ledger: 1 packaged flow(s) are switched off…' naming showcase_urgent_task_alert", + "verify": "post-restart GET /api/v1/automation/_status shows enabled:false, bound:false — the boot flow pull re-registers and re-arms every flow, and registerFlow deliberately does NOT re-arm a ledger-disabled one (engine.ts); hydrateFlowActivations then runs AFTER the pull (service-automation/src/plugin.ts) and disarms from the ledger (engine.ts). Corroborate with the log line '[Automation] Activation ledger: 1 packaged flow(s) are switched off…' naming showcase_urgent_task_alert", "evidence": "the post-restart /_status read + the boot-log excerpt" }, { @@ -1342,12 +1342,12 @@ { "clause": "re-enable UPDATES the row and never deletes it: after {enabled:true} the SAME row (same id) reads active=true and is still present, and the next urgent create fires the flow again", "oracle": "api", - "verify": "the step-9 re-read returns the step-4 row id with active=true — setActive is read-then-update and the store's engine slice deliberately has no delete (core/src/utils/metadata-activation-store.ts:221-250, :126-133): the ledger records the administrator's CHOICE (§6 wall 3), it does not erase it. Then one new run for the final urgent create", + "verify": "the step-9 re-read returns the step-4 row id with active=true — setActive is read-then-update and the store's engine slice deliberately has no delete (core/src/utils/metadata-activation-store.ts): the ledger records the administrator's CHOICE (§6 wall 3), it does not erase it. Then one new run for the final urgent create", "evidence": "the re-read row (id + active) + the runs list after the re-enable" } ], "negative": [ - "a toggle answering 2xx with NO sys_metadata_activation row written is a FAIL on this boot — the engine has a legitimate ledger-less degraded mode, but it WARNS 'IN PROCESS ONLY … will NOT survive a restart' (engine.ts:3019-3029) and the showcase composition attaches the ledger, so silence plus no row means the durable write was skipped where it was promised", + "a toggle answering 2xx with NO sys_metadata_activation row written is a FAIL on this boot — the engine has a legitimate ledger-less degraded mode, but it WARNS 'IN PROCESS ONLY … will NOT survive a restart' (engine.ts) and the showcase composition attaches the ledger, so silence plus no row means the durable write was skipped where it was promised", "a restart that re-arms the flow (enabled or bound true, or a run row from the post-restart create) is a FAIL against the whole point of ADR-0126 §7.2 — it is the retired flowEnabled map's behaviour reappearing", "a re-enable that DELETES the row is a FAIL of the row contract even though firing resumes — absence-means-active makes deletion look equivalent right up until something needs the recorded choice", "a run that scored the ledger clause from the FLOW_DISABLED code alone (without the message) has verified nothing this item asserts — the code is shared with the status dimension by design" @@ -1358,12 +1358,12 @@ ], "source": [ "docs/adr/0126-packaged-metadata-customization-model.md §4 (the generic ledger + row shape), §7.2 (the durable row replaces the process-local flowEnabled map; FLOW_DISABLED code reused, distinction rides the message)", - "packages/services/service-automation/src/engine.ts:2978-3043 (toggleFlow — durable row FIRST), :2888-2904 (describeDisabledFlow — the ledger message), :2851-2871 (hydrateFlowActivations), :2706-2714 (registerFlow does not re-arm a ledger-disabled flow — restart survival's other half), :1344-1382 (flowLedgerDisabled projection)", - "packages/services/service-automation/src/plugin.ts:986-1005 (hydrate AFTER the boot flow pull — the ordering that makes the unbind stick)", - "packages/core/src/utils/metadata-activation-store.ts:185-207 (org-carrying rows skipped on read), :221-250 (setActive read-then-update; organization_id never written), :126-133 (no delete on the engine slice)", - "packages/platform-objects/src/system/sys-metadata-activation.object.ts:152-158 (apiMethods ['get','list'] — reads open, generic-data-API writes refused)", + "packages/services/service-automation/src/engine.ts (toggleFlow — durable row FIRST), (describeDisabledFlow — the ledger message), (hydrateFlowActivations), (registerFlow does not re-arm a ledger-disabled flow — restart survival's other half), (flowLedgerDisabled projection)", + "packages/services/service-automation/src/plugin.ts (hydrate AFTER the boot flow pull — the ordering that makes the unbind stick)", + "packages/core/src/utils/metadata-activation-store.ts (org-carrying rows skipped on read), (setActive read-then-update; organization_id never written), (no delete on the engine slice)", + "packages/platform-objects/src/system/sys-metadata-activation.object.ts (apiMethods ['get','list'] — reads open, generic-data-API writes refused)", "packages/services/service-automation/src/flow-activation-ledger.test.ts (engine-level pins: hydration unbinds what a PREVIOUS process disabled, ledger-disabled survives re-registration — the HTTP+real-restart path here is what those pins cannot cover)", - "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow) + examples/app-showcase/objectstack.config.ts:80 (com.example.showcase)", + "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow) + examples/app-showcase/objectstack.config.ts (com.example.showcase)", "#12438 (sweep) · Epic #12150", "cross-refs: automation.flow-toggle-kill-switch (the single-boot kill-switch half — not repeated here), automation.durable-suspend-restart (the cold-restart recipe + file-DB discipline)" ], @@ -1390,10 +1390,10 @@ "fixtures": { "app": "showcase", "requires": [ - "the three stock packaged caller→child pairs (examples/app-showcase/src/automation/flows/index.ts): showcase_task_done_notify_owner → showcase_notify_owner via a subflow node (:678, :707) · showcase_project_closure → showcase_closure_signoff via subflow (:795, :816) · showcase_release_signoff → showcase_one_task_signoff via a MAP node (:1322, :1339) — the map pair is load-bearing, because a scan that read only `subflow` nodes would pass the first two and miss it" + "the three stock packaged caller→child pairs (examples/app-showcase/src/automation/flows/index.ts): showcase_task_done_notify_owner → showcase_notify_owner via a subflow node · showcase_project_closure → showcase_closure_signoff via subflow · showcase_release_signoff → showcase_one_task_signoff via a MAP node — the map pair is load-bearing, because a scan that read only `subflow` nodes would pass the first two and miss it" ], "knownGaps": [ - "two engine-pinned arms have no stock HTTP specimen and are scored from the unit pins, not driven: a NON-packaged caller must not guard (a tenant's own flow cannot hold a packaged one hostage) and a flow calling ITSELF must not guard its own disable — both pinned engine-level in packages/services/service-automation/src/flow-activation-ledger.test.ts (:372, :395); driving them over HTTP needs a scratch-package tenant caller nothing stock provides", + "two engine-pinned arms have no stock HTTP specimen and are scored from the unit pins, not driven: a NON-packaged caller must not guard (a tenant's own flow cannot hold a packaged one hostage) and a flow calling ITSELF must not guard its own disable — both pinned engine-level in packages/services/service-automation/src/flow-activation-ledger.test.ts; driving them over HTTP needs a scratch-package tenant caller nothing stock provides", "there is NO HTTP-level pin of this refusal anywhere (the pins above are engine unit tests against a fake store) — which is exactly why the wire shape (does the thrown {code:'DELETE_RESTRICTED', status:409} survive the dispatcher's error mapping intact?) is this item's job and not a formality" ] }, @@ -1410,37 +1410,37 @@ { "clause": "the subflow-caller disable is refused 409 with code DELETE_RESTRICTED — the standard catalog's cannot-do-this-due-to-dependencies member, NO new code minted — and the message names every packaged caller verbatim plus the remedy ('Disable the calling flow(s) first, or leave this one armed')", "oracle": "api", - "verify": "step 2's answer: HTTP 409, error.code DELETE_RESTRICTED (packages/spec/src/api/errors.zod.ts:96; the engine throws {code:'DELETE_RESTRICTED', status:409, subflowCallers:[…]} — engine.ts:2986-3007), message containing 'showcase_task_done_notify_owner' and the ADR-0126 §7.3 rationale (breaks mid-run at its subflow node). A 500, a bare 409 with a generic code, or a message that does not name the caller is a FAIL — the named caller IS the actionability §7.3 exists for", + "verify": "step 2's answer: HTTP 409, error.code DELETE_RESTRICTED (packages/spec/src/api/errors.zod.ts; the engine throws {code:'DELETE_RESTRICTED', status:409, subflowCallers:[…]} — engine.ts), message containing 'showcase_task_done_notify_owner' and the ADR-0126 §7.3 rationale (breaks mid-run at its subflow node). A 500, a bare 409 with a generic code, or a message that does not name the caller is a FAIL — the named caller IS the actionability §7.3 exists for", "evidence": "the full 409 body, callers quoted" }, { "clause": "MAP callers count as subflow callers: disabling showcase_one_task_signoff is refused naming showcase_release_signoff, whose invocation is a map node's per-item target, not a subflow node", "oracle": "api", - "verify": "step 3's answer names 'showcase_release_signoff'. The scan reads both node types through config.flowName (engine.ts:2932-2947) because a map's own descriptor calls its target 'the per-item subflow' — a pass on the subflow pairs with a silent 2xx here means the scan regressed to `subflow` only", + "verify": "step 3's answer names 'showcase_release_signoff'. The scan reads both node types through config.flowName (engine.ts) because a map's own descriptor calls its target 'the per-item subflow' — a pass on the subflow pairs with a silent 2xx here means the scan regressed to `subflow` only", "evidence": "the 409 body for the map pair" }, { "clause": "a refused attempt is a full no-op: NO sys_metadata_activation row is written for the refused flow, and the flow is still armed (a caller-driven run executes its subflow step)", "oracle": "api", - "verify": "the guard throws BEFORE the durable write (engine.ts:2984-3008 precedes the setActive at :3013) — so the data-API read shows no row for the refused name, and the step-4 caller run's step log shows the child executed. A row with active=false after a 409 means the refusal happened after the write, which is a half-disable nothing reports", + "verify": "the guard throws BEFORE the durable write (engine.ts precedes the setActive) — so the data-API read shows no row for the refused name, and the step-4 caller run's step log shows the child executed. A row with active=false after a 409 means the refusal happened after the write, which is a half-disable nothing reports", "evidence": "the ledger read (absence) + the caller run's step-log excerpt" }, { "clause": "ENABLE is never guarded — arming a flow cannot break a caller, so the enable arm answers 2xx regardless of callers", "oracle": "api", - "verify": "step 6's enable answers 2xx with no DELETE_RESTRICTED (guard is on the !enabled branch only, engine.ts:2984-2986; pinned engine-level at flow-activation-ledger.test.ts:384)", + "verify": "step 6's enable answers 2xx with no DELETE_RESTRICTED (guard is on the !enabled branch only, engine.ts; pinned engine-level at flow-activation-ledger.test.ts)", "evidence": "the enable response" }, { "clause": "the refusal's own remedy is followable: after disabling the calling flow first, the child's disable lands (2xx + ledger row active=false)", "oracle": "api", - "verify": "step 5's retry. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: packagedSubflowCallers scans the REGISTERED flow map with no activation check (engine.ts:2932-2947 — it skips self and non-packaged callers, nothing else), so a disabled caller still guards and the retry still answers 409 — while both the engine's own refusal message ('Disable the calling flow(s) first…') and ADR-0126 §7.3's rationale ('The refusal is honest, actionable (disable the callers first, or don't)') name exactly this sequence as the way out. A red here is a product finding about the remedy sentence — the door tells the administrator to do something it then refuses — already tracked by the #12438 sweep; record it, do NOT soften this clause to match the scan", + "verify": "step 5's retry. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: packagedSubflowCallers scans the REGISTERED flow map with no activation check (engine.ts — it skips self and non-packaged callers, nothing else), so a disabled caller still guards and the retry still answers 409 — while both the engine's own refusal message ('Disable the calling flow(s) first…') and ADR-0126 §7.3's rationale ('The refusal is honest, actionable (disable the callers first, or don't)') name exactly this sequence as the way out. A red here is a product finding about the remedy sentence — the door tells the administrator to do something it then refuses — already tracked by the #12438 sweep; record it, do NOT soften this clause to match the scan", "evidence": "the caller's successful disable + the retry's full answer, side by side with the first refusal's remedy sentence" } ], "negative": [ "a 2xx on step 2 or 3 (the disable landing despite packaged callers) is a FAIL of §7.3 itself — the vendor caller would then break mid-run at its subflow node with the late 'subflow … failed: Flow … is disabled' this guard exists to prevent", - "a refusal naming only the FIRST caller when several exist is a FAIL — the message must name every packaged caller (pinned engine-level, flow-activation-ledger.test.ts:335); with stock fixtures each child has one caller, so this arm rides the pin unless a scratch second caller is authored", + "a refusal naming only the FIRST caller when several exist is a FAIL — the message must name every packaged caller (pinned engine-level, flow-activation-ledger.test.ts); with stock fixtures each child has one caller, so this arm rides the pin unless a scratch second caller is authored", "the UI half — the caller names rendered verbatim in a role=alert on the Setup packaged-automation page — is deliberately NOT scored here; it is automation.setup-packaged-automation-board's refusal clause" ], "traps": [ @@ -1449,10 +1449,10 @@ ], "source": [ "docs/adr/0126-packaged-metadata-customization-model.md §7.3 (the subflow cascade: refused, callers named, attached to disable only)", - "packages/services/service-automation/src/engine.ts:2906-2947 (packagedSubflowCallers — definition scan at disable time, subflow AND map via config.flowName, non-packaged and self skipped), :2984-3008 (the guard, before the durable write; the thrown {code:'DELETE_RESTRICTED', status:409, subflowCallers})", - "packages/spec/src/api/errors.zod.ts:96 (DELETE_RESTRICTED — standard 409 catalog member, no new code minted; its DELETE_ prefix fits per the #10243 ruling quoted at the throw site)", - "packages/services/service-automation/src/flow-activation-ledger.test.ts:310-399 (the engine-level pins: names every caller, map counts, non-packaged and self do not guard, enable never guarded, no-callers lands)", - "examples/app-showcase/src/automation/flows/index.ts:678,707,795,816,1322,1339 (the three stock caller→child pairs)", + "packages/services/service-automation/src/engine.ts (packagedSubflowCallers — definition scan at disable time, subflow AND map via config.flowName, non-packaged and self skipped), (the guard, before the durable write; the thrown {code:'DELETE_RESTRICTED', status:409, subflowCallers})", + "packages/spec/src/api/errors.zod.ts (DELETE_RESTRICTED — standard 409 catalog member, no new code minted; its DELETE_ prefix fits per the #10243 ruling quoted at the throw site)", + "packages/services/service-automation/src/flow-activation-ledger.test.ts (the engine-level pins: names every caller, map counts, non-packaged and self do not guard, enable never guarded, no-callers lands)", + "examples/app-showcase/src/automation/flows/index.ts,707,795,816,1322,1339 (the three stock caller→child pairs)", "#12438 (sweep) · Epic #12150", "cross-ref: automation.packaged-flow-disable-durable (what a disable that LANDS must do); automation.setup-packaged-automation-board (the refusal rendered in the UI)" ], @@ -1460,7 +1460,7 @@ { "revision": 1, "date": "2026-08-26", - "change": "new — the ADR-0126 §7.3 disable guard had only engine-level unit pins and no HTTP-level assertion anywhere: the wire 409 DELETE_RESTRICTED with callers named, the map-caller arm, the no-row-on-refusal invariant, and the never-guarded enable. The remedy-sequence clause (disable the caller first) is authored as an expected fail: the shipped scan consults registration only (engine.ts:2932-2947), so the refusal's own named remedy does not currently unblock the child — the clause keeps the promise the door itself makes and flags the red as a tracked product finding", + "change": "new — the ADR-0126 §7.3 disable guard had only engine-level unit pins and no HTTP-level assertion anywhere: the wire 409 DELETE_RESTRICTED with callers named, the map-caller arm, the no-row-on-refusal invariant, and the never-guarded enable. The remedy-sequence clause (disable the caller first) is authored as an expected fail: the shipped scan consults registration only (engine.ts), so the refusal's own named remedy does not currently unblock the child — the clause keeps the promise the door itself makes and flags the red as a tracked product finding", "ref": "#12438" } ] @@ -1474,7 +1474,7 @@ "priority": "P1", "surface": "api", "personas": [ - "seeded admin (admin@objectos.ai / admin123 — holds manage_metadata; the clone door is in the #10145 authoring write set, domains/automation.ts:428-445)" + "seeded admin (admin@objectos.ai / admin123 — holds manage_metadata; the clone door is in the #10145 authoring write set, domains/automation.ts)" ], "fixtures": { "app": "showcase", @@ -1498,25 +1498,25 @@ { "clause": "name AND label are both mandatory: an empty body, a missing name, and a missing label each answer 400 with a per-field detail (code 'required'), and nothing reaches the service", "oracle": "api", - "verify": "the three refusals from step 2: 400, details naming the absent field (domains/automation.ts:1360-1388 — mandatory per ADR-0126 §7.1, the #11513 shape; an unknown body key answers 400 unknown_field). Route-level pins exist in packages/runtime/src/domains/automation-flow-clone.test.ts — this run proves the same arms on the live dispatcher", + "verify": "the three refusals from step 2: 400, details naming the absent field (domains/automation.ts — mandatory per ADR-0126 §7.1, the #11513 shape; an unknown body key answers 400 unknown_field). Route-level pins exist in packages/runtime/src/domains/automation-flow-clone.test.ts — this run proves the same arms on the live dispatcher", "evidence": "the three 400 bodies with their details arrays" }, { "clause": "the copy is whole-definition with exactly name/label/status mutated, the protection envelope and read decorations dropped, and NO ancestry: every other key of the clone deep-equals the source; none of the 9 FLOW_CLONE_DROPPED_KEYS (_diagnostics, _draft, _lock, _lockReason, _lockSource, _provenance, _packageId, _packageVersion, _lockDocsUrl) is present on the clone; no clonedFrom-shaped key exists in the response or the stored definition", "oracle": "api", - "verify": "the step-4 diff: differences are exactly {name, label, status:'draft'} (FLOW_CLONE_MUTATED_FIELDS, flow-clone.ts:115) plus the ABSENCE of the dropped keys (flow-clone.ts:132-135 — derived from MetadataProtectionFields + METADATA_READ_DECORATIONS, 7+2=9 today; count them from the spec at run time rather than trusting this line). A missing facet (nodes, edges, variables, runAs, retry…) is the #11703 param-list failure shape; a surviving _packageId or _lock defeats the feature (the clone would be package-owned or locked); any ancestry key violates amendment ruling 2 / §9", + "verify": "the step-4 diff: differences are exactly {name, label, status:'draft'} (FLOW_CLONE_MUTATED_FIELDS, flow-clone.ts) plus the ABSENCE of the dropped keys (flow-clone.ts — derived from MetadataProtectionFields + METADATA_READ_DECORATIONS, 7+2=9 today; count them from the spec at run time rather than trusting this line). A missing facet (nodes, edges, variables, runAs, retry…) is the #11703 param-list failure shape; a surviving _packageId or _lock defeats the feature (the clone would be package-owned or locked); any ancestry key violates amendment ruling 2 / §9", "evidence": "the key-by-key diff + the dropped-keys absence check" }, { "clause": "status 'draft' is NOT an off-switch and the response says so verbatim: one urgent create fires BOTH base and clone (double-fire), and the clone response's notice equals FLOW_CLONE_NOTICE", "oracle": "api", - "verify": "step 5: one new run in EACH flow's runs list for the same create — the engine disables on status obsolete/invalid only, draft binds its trigger (flow-clone.ts:90-104). The response notice matches flow-clone.ts:151-157 verbatim, including the toggle instruction — the sentence is the only thing standing between the admin and the two-flows-one-trigger surprise, so a paraphrase is a FAIL", + "verify": "step 5: one new run in EACH flow's runs list for the same create — the engine disables on status obsolete/invalid only, draft binds its trigger (flow-clone.ts). The response notice matches flow-clone.ts verbatim, including the toggle instruction — the sentence is the only thing standing between the admin and the two-flows-one-trigger surprise, so a paraphrase is a FAIL", "evidence": "both runs lists bracketing the create + the notice text from the clone response" }, { "clause": "the refusal arms answer their own status+code: same/taken name → 409 RESOURCE_CONFLICT with the shadow-rationale message suggesting a free name; unknown source → 404 RESOURCE_NOT_FOUND; an illegal machine name → 400 (the definition goes through registerFlow's own validation, one policy not two)", "oracle": "api", - "verify": "step 2's remaining arms: the 409 message explains WHY same-name clones are refused (engine keys flows by bare name — silent order-dependent shadowing, flowCloneNameTakenMessage flow-clone.ts:180-188) and suggests '_copy'; 404 for the unknown source; 400 for 'Bad-Name!' (FlowSchema name regex ^[a-z_][a-z0-9_]*$, flow.zod.ts:616). Codes pinned at automation-flow-clone.test.ts:368-401,439", + "verify": "step 2's remaining arms: the 409 message explains WHY same-name clones are refused (engine keys flows by bare name — silent order-dependent shadowing, flowCloneNameTakenMessage flow-clone.ts) and suggests '_copy'; 404 for the unknown source; 400 for 'Bad-Name!' (FlowSchema name regex ^[a-z_][a-z0-9_]*$, flow.zod.ts). Codes pinned at automation-flow-clone.test.ts,439", "evidence": "the three bodies (status + code + message)" }, { @@ -1528,13 +1528,13 @@ { "clause": "the clone is an ordinary tenant flow at the engine: PUT /api/v1/automation/qa_urgent_alert_clone lands (2xx, edit visible on re-read)", "oracle": "api", - "verify": "step 7. ⚠️ Scope note: this proves the clone is editable, NOT that the packaged base is protected at this door — PUT /automation/:name currently performs no lock/provenance check on ANY flow (domains/automation.ts:1826-1857), and that door-parity question is access-security.packaged-flow-write-door-parity's expected-fail probe, not this clause", + "verify": "step 7. ⚠️ Scope note: this proves the clone is editable, NOT that the packaged base is protected at this door — PUT /automation/:name currently performs no lock/provenance check on ANY flow (domains/automation.ts), and that door-parity question is access-security.packaged-flow-write-door-parity's expected-fail probe, not this clause", "evidence": "the PUT response + the re-read" }, { "clause": "the honest clause — the clone OUTLIVES the process and REACHES Studio: after a cold restart over the same database file the clone still exists and dispatches, and it is listed on a Studio Automations rail (it is 'an ordinary org/install-owned flow', ADR-0126 §7.1, under the shipped 'customize in Studio' promise §1.3 — the Setup page's own copy says 'Editing happens in Studio')", "oracle": "api", - "verify": "step 8: GET /api/v1/automation/qa_urgent_alert_clone on the new process, and the flow present in metadata a Studio rail can list. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: the clone route registers through automationService.registerFlow ONLY — no sys_metadata write anywhere in the arm (domains/automation.ts:1414-1436) — so the boot flow pull has nothing to re-register and the clone vanishes on restart; and the Studio Automations rail lists PACKAGE-scoped metadata (objectui StudioDesignSurface.tsx:3286 loadPackageSurfaces(client,'flow',packageId)) while the clone's _packageId was deliberately stripped (flow-clone.ts:225), so it is reachable in no rail. A red here is a product finding already tracked by the #12438 sweep (clone registered engine-only); record which half failed (durability, reachability, or both) — do NOT soften the clause to a same-process read", + "verify": "step 8: GET /api/v1/automation/qa_urgent_alert_clone on the new process, and the flow present in metadata a Studio rail can list. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: the clone route registers through automationService.registerFlow ONLY — no sys_metadata write anywhere in the arm (domains/automation.ts) — so the boot flow pull has nothing to re-register and the clone vanishes on restart; and the Studio Automations rail lists PACKAGE-scoped metadata (objectui StudioDesignSurface.tsx loadPackageSurfaces(client,'flow',packageId)) while the clone's _packageId was deliberately stripped (flow-clone.ts), so it is reachable in no rail. A red here is a product finding already tracked by the #12438 sweep (clone registered engine-only); record which half failed (durability, reachability, or both) — do NOT soften the clause to a same-process read", "evidence": "the post-restart GET (present or 404) + the meta/flow listing showing where, if anywhere, the clone is held" } ], @@ -1550,11 +1550,11 @@ ], "source": [ "docs/adr/0126-packaged-metadata-customization-model.md §7.1 (mandatory new name, whole-definition copy, no linkage), §9 (no re-pointing, no ancestry), §1.3 (the shipped 'customize in Studio' promise the honest clause holds the clone to)", - "packages/runtime/src/flow-clone.ts:105 (FLOW_CLONE_STATUS draft — and why it is deliberately not an off-switch), :115 (FLOW_CLONE_MUTATED_FIELDS), :132-135 (FLOW_CLONE_DROPPED_KEYS derived from spec), :151-157 (FLOW_CLONE_NOTICE), :160-188 (409 + name-taken message), :220-230 (deep copy via structuredClone; _packageId et al. stripped)", - "packages/runtime/src/domains/automation.ts:1340-1437 (the clone arm: body-first validation, mandatory name+label, 404/409 probes, registerFlow-only registration, no ancestry on the response), :428-445 (clone in the manage_metadata write set)", + "packages/runtime/src/flow-clone.ts (FLOW_CLONE_STATUS draft — and why it is deliberately not an off-switch), (FLOW_CLONE_MUTATED_FIELDS), (FLOW_CLONE_DROPPED_KEYS derived from spec), (FLOW_CLONE_NOTICE), (409 + name-taken message), (deep copy via structuredClone; _packageId et al. stripped)", + "packages/runtime/src/domains/automation.ts (the clone arm: body-first validation, mandatory name+label, 404/409 probes, registerFlow-only registration, no ancestry on the response), (clone in the manage_metadata write set)", "packages/runtime/src/domains/automation-flow-clone.test.ts (route-level pins for the validation and refusal arms — a mocked-service pin; it cannot see the live dispatcher, the double-fire, or the restart — which is exactly the remainder this item drives)", - "packages/spec/src/automation/flow.zod.ts:616 (machine-name regex)", - "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:3286 (the Automations rail is package-scoped — the reachability half of the honest clause)", + "packages/spec/src/automation/flow.zod.ts (machine-name regex)", + "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (the Automations rail is package-scoped — the reachability half of the honest clause)", "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow; TaskDoneNotifyOwnerFlow → showcase_notify_owner)", "#12438 (sweep) · Epic #12150", "cross-ref: access-security.packaged-flow-write-door-parity (owns the packaged-base write-door question this item's ordinariness clause must not be read as answering); automation.setup-packaged-automation-board (the clone dialog UI half)" @@ -1588,7 +1588,7 @@ "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 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", + "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); the Setup NAV METADATA that reaches it is objectstack's (packages/platform-objects/src/apps/setup-nav.contributions.ts — 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" ] }, @@ -1607,13 +1607,13 @@ { "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 — 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", + "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 (#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" }, { "clause": "inventory: the flows table lists exactly the packaged flows (default all On) and the packaged-actions section renders beside it; tenant/org-authored flows are ABSENT", "oracle": "dom", - "verify": "rows (data-testid packaged-flow-) match the isPackagedFlowItem filter — a real _packageId, not 'sys_metadata', and _provenance != 'org', joined against the engine runtime list as spine (objectui packagedFlows.ts:59-106). The clone created later must NOT appear (it is a tenant artifact). Erring either way is a real defect: a tenant flow shown gets an install-wide switch Studio owns; a packaged flow filtered out leaves no off-switch at all", + "verify": "rows (data-testid packaged-flow-) match the isPackagedFlowItem filter — a real _packageId, not 'sys_metadata', and _provenance != 'org', joined against the engine runtime list as spine (objectui packagedFlows.ts). The clone created later must NOT appear (it is a tenant artifact). Erring either way is a real defect: a tenant flow shown gets an install-wide switch Studio owns; a packaged flow filtered out leaves no off-switch at all", "evidence": "row list vs the two API reads, side by side" }, { @@ -1637,19 +1637,19 @@ { "clause": "server refusals render VERBATIM in a role=alert beside the control that caused them: the plain member's toggle shows the exact manage_metadata sentence; the admin's subflow toggle shows the 409 naming showcase_task_done_notify_owner; the local fallback strings appear nowhere", "oracle": "dom", - "verify": "the member's alert text equals 'Enabling or disabling an automation flow requires the `manage_metadata` capability.' (domains/automation.ts:333-335) and the admin's names the caller — actionErrorDetail reads error → error.message → message and falls back only when the body is unreadable (objectui core/src/actions/actionErrorDetail.ts:27-35; the alert at PackagedAutomationPage.tsx:427). The i18n fallbacks (packagedAutomation.toggleFailed / toggleFailedHttp) rendering instead of the server sentence means the body was dropped — the server's remedy ('disable the callers first' / the capability name) is the actionable half", + "verify": "the member's alert text equals 'Enabling or disabling an automation flow requires the `manage_metadata` capability.' (domains/automation.ts) and the admin's names the caller — actionErrorDetail reads error → error.message → message and falls back only when the body is unreadable (objectui core/src/actions/actionErrorDetail.ts; the alert at PackagedAutomationPage.tsx). The i18n fallbacks (packagedAutomation.toggleFailed / toggleFailedHttp) rendering instead of the server sentence means the body was dropped — the server's remedy ('disable the callers first' / the capability name) is the actionable half", "evidence": "both alert screenshots with the text transcribed" }, { "clause": "the clone dialog requires BOTH machine name and label before submit is possible, and a server-refused name surfaces legibly: the illegal-name 400 renders in the dialog's alert — record exactly what text arrives, because the per-field details[] are dropped client-side", "oracle": "dom", - "verify": "the submit control is disabled until both fields are non-empty (cloneNameValid, PackagedAutomationPage.tsx:327,529 — so the 'required' 400s of the API item are unreachable from this dialog); submitting 'Bad-Name!' round-trips the server 400 and the dialog alert (role=alert, :516) shows the message actionErrorDetail extracted — details[] never reach the DOM (:27-35), so if the rendered sentence does not state the ^[a-z_][a-z0-9_]*$ constraint legibly, record what DID render as the finding rather than inventing a pass. A legal clone then shows the server's post-clone notice verbatim", + "verify": "the submit control is disabled until both fields are non-empty (cloneNameValid, PackagedAutomationPage.tsx,529 — so the 'required' 400s of the API item are unreachable from this dialog); submitting 'Bad-Name!' round-trips the server 400 and the dialog alert (role=alert) shows the message actionErrorDetail extracted — details[] never reach the DOM, so if the rendered sentence does not state the ^[a-z_][a-z0-9_]*$ constraint legibly, record what DID render as the finding rather than inventing a pass. A legal clone then shows the server's post-clone notice verbatim", "evidence": "dialog screenshots: disabled submit, the 400 alert text, the post-clone notice" }, { "clause": "posture record (not a pass/fail gate): a plain member REACHES the page and sees the switches — the component route has no capability gate and the ledger/meta reads are open — with the refusal arriving only after a click; record the posture as observed", "oracle": "dom", - "verify": "ComponentNavView resolves the ref with no authz check (objectui ComponentNavView.tsx:26-60) and sys_metadata_activation reads are open by ruling (sys-metadata-activation.object.ts:152-158), so the member sees real install-wide state with dead controls. Whether that read-exposure is intended is a maintainer question tracked in the #12438 sweep's follow-ups — this clause RECORDS what a member sees (which lists load, what the first refusal moment is); it does not adjudicate", + "verify": "ComponentNavView resolves the ref with no authz check (objectui ComponentNavView.tsx) and sys_metadata_activation reads are open by ruling (sys-metadata-activation.object.ts), so the member sees real install-wide state with dead controls. Whether that read-exposure is intended is a maintainer question tracked in the #12438 sweep's follow-ups — this clause RECORDS what a member sees (which lists load, what the first refusal moment is); it does not adjudicate", "evidence": "the member's pre-click page screenshot + the list of requests the page made and their statuses" } ], @@ -1670,12 +1670,12 @@ }, "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: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)", + "objectui packages/app-shell/src/views/setup/PackagedAutomationPage.tsx (529 clone-dialog validation,516 role=alert refusal rendering) + PackagedActionsSection.tsx (header: ⛔ no clone on actions) + packagedFlows.ts (isPackagedFlowItem + runtime-spine join)", + "objectui packages/app-shell/src/services/builtinComponents.tsx (registerAppComponent ref 'automation:packaged') + views/ComponentNavView.tsx (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 (error → error.message → message; details[] dropped)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (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 (the exact enablement 403 sentence)", + "packages/platform-objects/src/system/sys-metadata-activation.object.ts (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) → #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)" @@ -1690,7 +1690,7 @@ { "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", + "change": "inverted the nav clause from expected-fail to a positive assertion: #12457 landed the missing framework nav entry (setup-nav.contributions.ts — 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 d5616a6de3..0c8f36ddc6 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -821,7 +821,7 @@ { "clause": "the scaffold's OWN `dev` script serves the console with NO extra flag — `npm run dev` (bare `objectstack dev`) reaches /_console/ exactly as `os dev --ui` does", "oracle": "network", - "verify": "probe /_console/ under both invocations; both must serve. The console is default-ON downstream: `dev` spawns `serve` (packages/cli/src/commands/dev.ts:370) forwarding `--ui` only when set and never forwarding `--no-ui`, and serve declares `ui` with `default: true, allowNo: true` (packages/cli/src/commands/serve.ts:221) — so an unflagged `dev` lands on serve's own default. `--ui` on `dev` is a no-op forwarder, which is what content/docs/deployment/cli.mdx:139 means by 'Force Console UI on (already on by default in dev)'. A difference between the two invocations is the FAILURE here, not the expectation", + "verify": "probe /_console/ under both invocations; both must serve. The console is default-ON downstream: `dev` spawns `serve` (packages/cli/src/commands/dev.ts) forwarding `--ui` only when set and never forwarding `--no-ui`, and serve declares `ui` with `default: true, allowNo: true` (packages/cli/src/commands/serve.ts) — so an unflagged `dev` lands on serve's own default. `--ui` on `dev` is a no-op forwarder, which is what content/docs/deployment/cli.mdx means by 'Force Console UI on (already on by default in dev)'. A difference between the two invocations is the FAILURE here, not the expectation", "evidence": "both probe statuses" }, { @@ -833,7 +833,7 @@ ], "negative": [ "the console 404s on a fresh scaffold while working on the showcase — a first-run-only failure that every showcase-based test is blind to", - "`npm run dev` and `os dev --ui` disagreeing about /_console/ — that would mean the forwarding in dev.ts:370 or the default in serve.ts:221 changed, and the newcomer path silently regressed", + "`npm run dev` and `os dev --ui` disagreeing about /_console/ — that would mean the forwarding in dev.ts or the default in serve.ts changed, and the newcomer path silently regressed", "a future edit giving `dev` its own `ui` default of false, or forwarding `--no-ui`: either would strand the newcomer, and neither would be caught by any showcase-driven item", "os validate refusing on a pristine scaffold", "a console that returns 200 and paints nothing (white screen) — 200 is not the oracle here, the rendered DOM is" @@ -842,9 +842,9 @@ "source": [ "content/docs/getting-started/quick-start.mdx ('How you verify' — `npx os validate` and `npx os dev --ui`, then open http://localhost:3000/_console/)", "packages/create-objectstack/src/templates/blank/package.json (scripts.dev = `objectstack dev`, with no --ui — correct, see the chain below)", - "packages/cli/src/commands/serve.ts:221 (`ui: Flags.boolean({ ..., default: true, allowNo: true })` — the console is default-ON here; `--no-ui` is the off switch)", - "packages/cli/src/commands/dev.ts:69 + :370 (dev declares `ui` with NO default and forwards `...(flags.ui ? ['--ui'] : [])` — it only ever ADDS the flag, never forwards --no-ui, so an unflagged dev inherits serve's default-on)", - "content/docs/deployment/cli.mdx:139 ('--ui | Force Console UI on (already on by default in dev)') and :196 ('--ui / --no-ui — Toggle Console UI at /_console/ (default on)')", + "packages/cli/src/commands/serve.ts (`ui: Flags.boolean({..., default: true, allowNo: true })` — the console is default-ON here; `--no-ui` is the off switch)", + "packages/cli/src/commands/dev.ts + (dev declares `ui` with NO default and forwards `...(flags.ui ? ['--ui'] : [])` — it only ever ADDS the flag, never forwards --no-ui, so an unflagged dev inherits serve's default-on)", + "content/docs/deployment/cli.mdx ('--ui | Force Console UI on (already on by default in dev)') and ('--ui / --no-ui — Toggle Console UI at /_console/ (default on)')", "packages/create-objectstack/src/template-registry.ts (TEMPLATES = { blank } only; RETIRED_TEMPLATES named for a named refusal)", "sibling item cli.scaffold-first-run (scaffold→install→validate→build→`os start --artifact`→health/ready; automated by .github/workflows/scaffold-e2e.yml)", "sibling item cli.dev-boot-contract (os dev --ui + /_console/ sign-in, driven against examples/app-showcase)", @@ -854,7 +854,7 @@ { "revision": 1, "date": "2026-08-17", - "change": "new — angle-5 (docs claims) probe of the published first-run. Covers a real seam between two existing items: cli.scaffold-first-run boots the scaffold headless via `os start --artifact`, cli.dev-boot-contract drives the console against the fully-populated showcase, and nobody drives the console against a fresh BLANK scaffold — where there is no seed data and no seeded admin unless one is requested. Checked and CLEAN on three questions the sweep raised: no doc anywhere in content/docs/ prescribes a retired template (`todo`/`compliance`/`content`/`contracts`/`procurement`), every documented invocation is a bare `npx create-objectstack my-app` resolving to the only catalog entry, and `npm run validate` is the same binary as `npx os validate`. REFUTED during authoring, recorded here so it is not re-derived: the sweep first read the blank template's bare `\"dev\": \"objectstack dev\"` against the quick-start's `os dev --ui` and inferred that `npm run dev` would strand a newcomer with no console. Source-checking the chain disproves it — dev.ts:370 forwards `--ui` only when set and never forwards `--no-ui`, serve.ts:221 declares `ui` `default: true`, and cli.mdx:139 states outright that the console is 'already on by default in dev'. The bare script is CORRECT; `--ui` on `dev` is a no-op forwarder. Clause 3 accordingly asserts the two invocations AGREE, and a difference is the failure", + "change": "new — angle-5 (docs claims) probe of the published first-run. Covers a real seam between two existing items: cli.scaffold-first-run boots the scaffold headless via `os start --artifact`, cli.dev-boot-contract drives the console against the fully-populated showcase, and nobody drives the console against a fresh BLANK scaffold — where there is no seed data and no seeded admin unless one is requested. Checked and CLEAN on three questions the sweep raised: no doc anywhere in content/docs/ prescribes a retired template (`todo`/`compliance`/`content`/`contracts`/`procurement`), every documented invocation is a bare `npx create-objectstack my-app` resolving to the only catalog entry, and `npm run validate` is the same binary as `npx os validate`. REFUTED during authoring, recorded here so it is not re-derived: the sweep first read the blank template's bare `\"dev\": \"objectstack dev\"` against the quick-start's `os dev --ui` and inferred that `npm run dev` would strand a newcomer with no console. Source-checking the chain disproves it — dev.ts forwards `--ui` only when set and never forwards `--no-ui`, serve.ts declares `ui` `default: true`, and cli.mdx states outright that the console is 'already on by default in dev'. The bare script is CORRECT; `--ui` on `dev` is a no-op forwarder. Clause 3 accordingly asserts the two invocations AGREE, and a difference is the failure", "ref": "#9299" }, { "revision": 2, "date": "2026-08-18", "change": "corrected the stale 'no seeded admin unless one is requested' assumption in both knownGaps. commands/dev.ts resolves seed-admin as flags['seed-admin'] ?? true and documents it 'Default: on', so a bare `objectstack dev` on an empty DB seeds admin@objectos.ai / admin123 and prints it in the banner. Clause 4's open question ('is there any identity a newcomer can sign in with?') has a definite answer, and leaving the gap as written told the runner not to assume the answer the tree already fixes (#9467 CF-6)", "ref": "#9386" } @@ -878,65 +878,65 @@ ], "knownGaps": [ "no stock fixture ships a config with an orphan view / circular lookup / unused object, so the config-analysis rows must be staged; record the staged shapes in evidence rather than skipping the rows silently", - "the config-analysis block runs only `if (configExists())` (doctor.ts:2064) — a bare directory exercises the environment rows alone; run both shapes and label which produced which rows" + "the config-analysis block runs only `if (configExists())` (doctor.ts) — a bare directory exercises the environment rows alone; run both shapes and label which produced which rows" ] }, "steps": [ "baseline: from the untouched scaffold run `os doctor; echo $?` and `os doctor -v; echo $?` — capture both full reports (the unconditional environment rows print first: Node.js, pnpm, TypeScript, Dependencies, Git, Environment files — then whichever finding-only rows are live, then the extended checks)", "stage the config findings in the scratch copy: a view whose object does not exist (orphan), two objects whose lookup fields point at each other (circular), and one object nothing references (unused); run `os doctor` and capture the three warning rows — then restore and capture the three ✓ rows (Dependencies / Object usage / View integrity)", - "unparseable posture: `OS_TENANCY_POSTURE=isolatd os doctor; echo $?` — the Tenancy posture ERROR row must print naming the variable and the allowed set, the REST of the report must still run, and the exit must be 1 (#5382: report-not-refuse, resolved OUTSIDE the config-analysis try at doctor.ts:1752-1753, row pushed at :1891-1893)", + "unparseable posture: `OS_TENANCY_POSTURE=isolatd os doctor; echo $?` — the Tenancy posture ERROR row must print naming the variable and the allowed set, the REST of the report must still run, and the exit must be 1 (#5382: report-not-refuse, resolved OUTSIDE the config-analysis try at doctor.ts, row pushed)", "env provenance: put the same bad posture in a .env FILE (not the shell) and re-run — the finding must still fire (#5387 dotenv cascade) and the Environment files row must attribute the value to the file that supplied it", "ledger: write .objectstack/installed-packages/broken.json holding non-JSON; run `os doctor` with NO posture set (default `single`) and capture the Installed packages row; then with a valid config and OS_TENANCY_POSTURE=isolated, confirm the run does NOT print the 'Unique scope ✓' success line while that entry is unreadable", "exit split: on a warnings-only run capture `echo $?` (expect 0, '⚠️ Environment is functional' summary); on the posture-error run expect 1 ('❌ Some critical issues found' + the collected fix lines)", - "the monorepo-anchored skips: in the scaffold — which has NO packages/spec/src — capture verbatim the two informational rows 'ℹ Test coverage Skipped — no packages/spec/src in this directory (monorepo-only check)' and 'ℹ Deprecations Skipped — no packages/spec/src in this directory (monorepo-only check)', then re-run with -v and capture the '→ Looked for /packages/spec/src and found nothing to walk. …' detail line under each. Assert the ABSENCE of three lines in the SAME transcript: '✓ Test coverage All *.zod.ts files have matching tests', '✓ Deprecations No @deprecated tags found', and the '@objectstack/spec Not built' warning row — that probe is now gated on /packages/spec/package.json (doctor.ts:1929), so in a user app the row is correctly not printed at all. Then run the same command from a monorepo checkout for the counterpart transcript: there the '→ Checking…' step lines, the scan's own ✓/⚠ rows and '✓ @objectstack/spec Built' all print" + "the monorepo-anchored skips: in the scaffold — which has NO packages/spec/src — capture verbatim the two informational rows 'ℹ Test coverage Skipped — no packages/spec/src in this directory (monorepo-only check)' and 'ℹ Deprecations Skipped — no packages/spec/src in this directory (monorepo-only check)', then re-run with -v and capture the '→ Looked for /packages/spec/src and found nothing to walk. …' detail line under each. Assert the ABSENCE of three lines in the SAME transcript: '✓ Test coverage All *.zod.ts files have matching tests', '✓ Deprecations No @deprecated tags found', and the '@objectstack/spec Not built' warning row — that probe is now gated on /packages/spec/package.json (doctor.ts), so in a user app the row is correctly not printed at all. Then run the same command from a monorepo checkout for the counterpart transcript: there the '→ Checking…' step lines, the scan's own ✓/⚠ rows and '✓ @objectstack/spec Built' all print" ], "acceptance": [ { "clause": "every environment check emits its own row, and the finding-only rows appear exactly when their finding is live — Node.js, pnpm, TypeScript, Dependencies, Git and Environment files are unconditional; @objectstack/spec prints only where /packages/spec/package.json exists (#10679), NODE_ENV only while NODE_ENV is unset (#5673), Tenancy posture only on an unrecognized value (#5382), and Installed packages only on a ledger failure or a skipped entry (#5429) — and -v adds the fix/detail lines without changing the verdicts", "oracle": "log", - "verify": "both transcripts carry one row per check through renderHealthCheckResult (doctor.ts:2014-2022); the six unconditional rows are pushed across doctor.ts:1838-1971 and the four finding-only rows at :1929-1948 (spec workspace, gated on the package.json probe), :1978-1981 (nodeEnvCheck), :1987-1989 (postureReading) and :2006-2007 (installedPackageLedgerChecks, which returns [] on a clean ledger); the -v run differs only by detail lines", + "verify": "both transcripts carry one row per check through renderHealthCheckResult (doctor.ts); the six unconditional rows are pushed across doctor.ts and the four finding-only rows (spec workspace, gated on the package.json probe), (nodeEnvCheck), (postureReading) and (installedPackageLedgerChecks, which returns [] on a clean ledger); the -v run differs only by detail lines", "evidence": "the paired scaffold transcripts, plus the same pair from a monorepo cwd — a conditional row that is absent and a row that was dropped read identically in one transcript alone" }, { "clause": "#5387 env provenance: doctor resolves the same .env* cascade os serve reads, and the Environment files row names WHICH source supplied each value — the files are never silently merged into process.env", "oracle": "log", - "verify": "the .env-staged posture still produces the finding, and the Environment files row attributes it to that file rather than the shell (environmentSourcesCheck at doctor.ts:1971; pinned by doctor-env-provenance.test.ts — cite its pass as the attribution oracle, drive the CLI for the row itself)", + "verify": "the.env-staged posture still produces the finding, and the Environment files row attributes it to that file rather than the shell (environmentSourcesCheck at doctor.ts; pinned by doctor-env-provenance.test.ts — cite its pass as the attribution oracle, drive the CLI for the row itself)", "evidence": "the transcript + the .env file staged" }, { "clause": "#5382 posture honesty: an unrecognized OS_TENANCY_POSTURE is an ERROR row naming the variable — never swallowed by the config-analysis catch as 'Could not load config for analysis' — the rest of the report still runs, and the summary owns exit 1", "oracle": "log", - "verify": "the isolatd run prints the Tenancy posture error row, later report sections still print after it, and echo $? is 1 (posture resolved once at doctor.ts:1828-1829, outside every try; exit via the hasErrors summary at :2296-2302)", + "verify": "the isolatd run prints the Tenancy posture error row, later report sections still print after it, and echo $? is 1 (posture resolved once at doctor.ts, outside every try; exit via the hasErrors summary)", "evidence": "transcript + exit code" }, { "clause": "#5429 ledger independence: installed-package ledger rows print under EVERY posture including the default `single` — an unreadable entry under .objectstack/installed-packages/ is its own row whether or not a config loaded and whether or not any posture was set", "oracle": "log", - "verify": "the broken.json run with no posture set carries the Installed packages failure row (readInstalledPackageEntries called unconditionally at doctor.ts:2006-2007; posture-independence pinned by doctor-ledger-posture-independence.test.ts + doctor-ledger-read-failure.test.ts)", + "verify": "the broken.json run with no posture set carries the Installed packages failure row (readInstalledPackageEntries called unconditionally at doctor.ts; posture-independence pinned by doctor-ledger-posture-independence.test.ts + doctor-ledger-read-failure.test.ts)", "evidence": "the default-posture transcript" }, { "clause": "the unique-scope ✓ is WITHHELD when the ledger read was incomplete: under `isolated` with an unreadable ledger entry, the 'Unique scope' success line must NOT print — a ✓ over an unexamined ledger is a false PASS (#5412/#5413/#5644)", "oracle": "log", - "verify": "the isolated+broken-ledger run prints no 'Unique scope … No unconfirmed installation-wide uniques' line (gated on ledgerReadingIsComplete at doctor.ts:2172-2174), while the same run with the entry removed prints it", + "verify": "the isolated+broken-ledger run prints no 'Unique scope … No unconfirmed installation-wide uniques' line (gated on ledgerReadingIsComplete at doctor.ts), while the same run with the entry removed prints it", "evidence": "the two isolated-run transcripts diffed on that line" }, { "clause": "config-analysis rows both ways: the staged orphan view, circular lookup and unused object each produce a warning row naming the offender, and the restored config produces the three ✓ rows", "oracle": "log", - "verify": "detectCircularDependencies / findUnusedObjects / findOrphanViews rows (called at doctor.ts:2110, :2122 and :2180) name the staged shapes; the clean run prints the 'Dependencies' (:2117), 'Object usage' (:2129) and 'View integrity' (:2187) successes", + "verify": "detectCircularDependencies / findUnusedObjects / findOrphanViews rows (called at doctor.ts) name the staged shapes; the clean run prints the 'Dependencies', 'Object usage' and 'View integrity' successes", "evidence": "the staged and clean transcripts" }, { "clause": "exit contract: 1 exactly when any ERROR row exists; warnings alone exit 0 under the '⚠️ Environment is functional' summary — warnings never flip the exit", "oracle": "log", - "verify": "echo $? per run matches the summary branch taken (process.exit(1) only under hasErrors, doctor.ts:2296-2307)", + "verify": "echo $? per run matches the summary branch taken (process.exit(1) only under hasErrors, doctor.ts)", "evidence": "exit codes paired with summary lines" }, { - "clause": "the two monorepo-anchored scans report whether they RAN, and the ✓ is reachable only from the arm that did (#10679 — this clause was the EXPECTED-FAIL at revision 1 and is now an ordinary pass): findMissingTests and findDeprecatedUsages return a MonorepoTreeScan (doctor.ts:1173-1175, :1217-1233 and :1235-1257) whose `scanned: false` arm is taken in any app without /packages/spec/src. There doctor prints neither ✓ and neither '→ Checking…' step line; each check KEEPS its name column and prints an informational skip naming the reason, and -v adds the directory doctor resolved and looked for. The skip goes through printInfo, never printWarning — withholding a false ✓ must not manufacture a false ⚠ (rationale at :1187-1191)", + "clause": "the two monorepo-anchored scans report whether they RAN, and the ✓ is reachable only from the arm that did (#10679 — this clause was the EXPECTED-FAIL at revision 1 and is now an ordinary pass): findMissingTests and findDeprecatedUsages return a MonorepoTreeScan (doctor.ts) whose `scanned: false` arm is taken in any app without /packages/spec/src. There doctor prints neither ✓ and neither '→ Checking…' step line; each check KEEPS its name column and prints an informational skip naming the reason, and -v adds the directory doctor resolved and looked for. The skip goes through printInfo, never printWarning — withholding a false ✓ must not manufacture a false ⚠ (rationale)", "oracle": "log", - "verify": "in the scaffold (no packages/spec/src anywhere) the report carries 'ℹ Test coverage Skipped — no packages/spec/src in this directory (monorepo-only check)' and the same row for 'Deprecations' (monorepoTreeSkipNotice :1193-1206, printed at :2034 and :2050); the two ✓ lines (:2043, :2059) and their step lines are absent; and the skip rows move neither hasErrors nor hasWarnings, so the summary branch is the one the other rows earned. The -v run adds '→ Looked for /packages/spec/src and found nothing to walk. …' under each. From a monorepo checkout the scanned arm still runs and its ✓/⚠ findings still print — the fix scoped the check, it did not delete it", + "verify": "in the scaffold (no packages/spec/src anywhere) the report carries 'ℹ Test coverage Skipped — no packages/spec/src in this directory (monorepo-only check)' and the same row for 'Deprecations' (monorepoTreeSkipNotice, printed); the two ✓ lines and their step lines are absent; and the skip rows move neither hasErrors nor hasWarnings, so the summary branch is the one the other rows earned. The -v run adds '→ Looked for /packages/spec/src and found nothing to walk. …' under each. From a monorepo checkout the scanned arm still runs and its ✓/⚠ findings still print — the fix scoped the check, it did not delete it", "evidence": "the verbatim skip rows from both the plain and the -v scaffold run, an `ls packages/spec/src` refusal from the same cwd, and the monorepo-cwd transcript showing the scanned arm still reporting" } ], @@ -944,20 +944,20 @@ "an unrecognized posture reported only as 'Could not load config for analysis' with exit 0 is the exact #5382 regression the placement fix closed", "a 'Unique scope ✓' printed while an installed-packages entry was unreadable is a false PASS — worse than a missing check, because it stops the operator looking further", "warnings flipping the exit to 1, or an error row exiting 0, breaks every CI wrapper that gates on doctor", - "a '@objectstack/spec Not built' WARNING in a user app is a REGRESSION of #10679, not a note to record: outside this checkout there is no such workspace, and that row used to flip every stock scaffold to '⚠️ Environment is functional but has some warnings' while prescribing `pnpm --filter @objectstack/spec build`, a command that cannot succeed there. The probe is now gated on /packages/spec/package.json (doctor.ts:1929) and the honest report in a user app is no row at all", + "a '@objectstack/spec Not built' WARNING in a user app is a REGRESSION of #10679, not a note to record: outside this checkout there is no such workspace, and that row used to flip every stock scaffold to '⚠️ Environment is functional but has some warnings' while prescribing `pnpm --filter @objectstack/spec build`, a command that cannot succeed there. The probe is now gated on /packages/spec/package.json (doctor.ts) and the honest report in a user app is no row at all", "a ⚠ rather than a ℹ on the Test coverage / Deprecations skip rows is the same false-signal class inverted: nothing is wrong in an app that has no packages/spec/src, and routing the skip through printWarning would end every healthy user app's report on 'Environment is functional but has some warnings'", "the skip rows disappearing instead of printing is the other wrong shape — an operator scans the report by its name column, so a check that did not run must be PRESENT and say so" ], "traps": ["absence-inference", "stale-dist"], "source": [ - "packages/cli/src/commands/doctor.ts (class at :1788, flags -v/--scan-deprecations :1792-1793, run body :1796-2311; posture resolve :1828-1829 + row :1987-1989; env sources row :1971; NODE_ENV row :1978-1981; spec-workspace probe :1929-1948; ledger read :2006-2007; unique-scope withhold :2172-2174; MonorepoTreeScan :1173-1175, monorepoTreeSkipNotice :1193-1206, printMonorepoTreeSkip :1209-1215, findMissingTests :1217-1233, findDeprecatedUsages :1235-1257, their print sites :2032-2045 and :2048-2061; summary exit :2296-2307)", + "packages/cli/src/commands/doctor.ts (class, flags -v/--scan-deprecations, run body; posture resolve + row; env sources row; NODE_ENV row; spec-workspace probe; ledger read; unique-scope withhold; MonorepoTreeScan, monorepoTreeSkipNotice, printMonorepoTreeSkip, findMissingTests, findDeprecatedUsages, their print sites; summary exit)", "packages/cli/src/commands/doctor-env-provenance.test.ts, doctor-tenancy-posture-report.test.ts, doctor-ledger-dir-authority.test.ts, doctor-ledger-posture-independence.test.ts, doctor-ledger-read-failure.test.ts, doctor-config-load-cause.test.ts, doctor-node-env-default.test.ts, doctor-unexamined-spec-tree.test.ts (the unit pins — they cover the row-building seams, none drives the CLI end-to-end, which is why this item carries no automated entry)", "sibling item cli.flag-command-error-ux (owns only `os doctor --help`; this item is the command's first functional coverage)", "sibling item cli.doctor-deprecation-scan (the --scan-deprecations flag's own item — cross-referenced, not duplicated)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os doctor` as a whole had NO checklist item; it appeared only as a --help variant on cli.flag-command-error-ux while carrying seven unit-pinned seams (env provenance #5387, posture honesty #5382, ledger independence #5429, withheld ✓ #5412/#5413/#5644). Authored from the run body at doctor.ts:1720-2172 with the withhold-the-✓ discipline the command itself established turned back on its own monorepo-anchored Test-coverage/Deprecations ✓s, which bless a tree they never examined in every user app — encoded as an expected-fail probe rather than silently accepted", "ref": "claude/new-session-0pv25p" }, - { "revision": 2, "date": "2026-08-23", "change": "text refresh only, no capability change: #10679 landed the fix this item's expected-fail predicted, so that clause inverts into an ordinary pass. findMissingTests/findDeprecatedUsages now return a MonorepoTreeScan and the ✓ is reachable only from the scanned arm; outside a monorepo each check prints 'ℹ Skipped — no packages/spec/src in this directory (monorepo-only check)', with the resolved directory under -v. Rewritten against a REAL run of the shipped CLI in a `create-objectstack -t blank` scaffold and again from a monorepo checkout, not from source: (a) the expected-fail clause, step 7 and the item title inverted; (b) the row enumeration in acceptance[0] and step 1 corrected — measurement falsified the premise that only @objectstack/spec became conditional: `Installed packages` was ALREADY findings-only (installedPackageLedgerChecks returns [] on a clean ledger) and had never been an unconditional row, and NODE_ENV/Tenancy posture are finding-only too, so the enumeration now splits unconditional from finding-only rather than naming one exception; (c) the '@objectstack/spec Not built' negative flipped from an accepted note into a regression, its probe being gated at :1929; (d) two negatives added for the skip row's own wrong shapes (a ⚠ instead of a ℹ, and the row vanishing); (e) doctor-unexamined-spec-tree.test.ts added to the pins; (f) every line anchor in the item re-derived at this base — doctor.ts grew ~90 lines, so every number quoted at revision 1 had moved", "ref": "#10798" } + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os doctor` as a whole had NO checklist item; it appeared only as a --help variant on cli.flag-command-error-ux while carrying seven unit-pinned seams (env provenance #5387, posture honesty #5382, ledger independence #5429, withheld ✓ #5412/#5413/#5644). Authored from the run body at doctor.ts with the withhold-the-✓ discipline the command itself established turned back on its own monorepo-anchored Test-coverage/Deprecations ✓s, which bless a tree they never examined in every user app — encoded as an expected-fail probe rather than silently accepted", "ref": "claude/new-session-0pv25p" }, + { "revision": 2, "date": "2026-08-23", "change": "text refresh only, no capability change: #10679 landed the fix this item's expected-fail predicted, so that clause inverts into an ordinary pass. findMissingTests/findDeprecatedUsages now return a MonorepoTreeScan and the ✓ is reachable only from the scanned arm; outside a monorepo each check prints 'ℹ Skipped — no packages/spec/src in this directory (monorepo-only check)', with the resolved directory under -v. Rewritten against a REAL run of the shipped CLI in a `create-objectstack -t blank` scaffold and again from a monorepo checkout, not from source: (a) the expected-fail clause, step 7 and the item title inverted; (b) the row enumeration in acceptance[0] and step 1 corrected — measurement falsified the premise that only @objectstack/spec became conditional: `Installed packages` was ALREADY findings-only (installedPackageLedgerChecks returns [] on a clean ledger) and had never been an unconditional row, and NODE_ENV/Tenancy posture are finding-only too, so the enumeration now splits unconditional from finding-only rather than naming one exception; (c) the '@objectstack/spec Not built' negative flipped from an accepted note into a regression, its probe being gated; (d) two negatives added for the skip row's own wrong shapes (a ⚠ instead of a ℹ, and the row vanishing); (e) doctor-unexamined-spec-tree.test.ts added to the pins; (f) every line anchor in the item re-derived at this base — doctor.ts grew ~90 lines, so every number quoted at revision 1 had moved", "ref": "#10798" } ] }, { @@ -981,48 +981,48 @@ ] }, "steps": [ - "scaffold and seed src/legacy.ts with one line per DEPRECATED_PATTERNS entry — count the table yourself at doctor.ts:1262-1310 (recounted at this revision's base: still 8 entries): EnhancedObjectKernel, max_length, default_value, min_length, the retired lookup-scoping pair (one entry, BOTH spellings: reference_filters AND referenceFilters — seed both lines), unique_name, `import ... from '@objectstack/core/enhanced'`, `import ... from '@objectstack/spec/dist/x'`", + "scaffold and seed src/legacy.ts with one line per DEPRECATED_PATTERNS entry — count the table yourself at doctor.ts (recounted at this revision's base: still 8 entries): EnhancedObjectKernel, max_length, default_value, min_length, the retired lookup-scoping pair (one entry, BOTH spellings: reference_filters AND referenceFilters — seed both lines), unique_name, `import... from '@objectstack/core/enhanced'`, `import... from '@objectstack/spec/dist/x'`", "stage the decoys: src/legacy.test.ts with the same content, src/node_modules/dep/index.ts with the same content, and src/meta.json carrying \"referenceFilters\"", - "run `os doctor --scan-deprecations; echo $?` and capture the per-hit rows and the closing hint line verbatim; re-run with `--scan-deprecations --verbose` and capture the replacement prescriptions AND that run's closing hint line too — the hint is a two-branch template (doctor.ts:2283-2288) and the two runs do not print the same sentence", + "run `os doctor --scan-deprecations; echo $?` and capture the per-hit rows and the closing hint line verbatim; re-run with `--scan-deprecations --verbose` and capture the replacement prescriptions AND that run's closing hint line too — the hint is a two-branch template (doctor.ts) and the two runs do not print the same sentence", "clean pass: delete the seeds (keep the decoys) and re-run — expect '✓ Deprecation scan No deprecated patterns found' and exit 0", "absent-src probe: run `os doctor --scan-deprecations` from a scratch dir with NO src/ at all and capture that the SAME success line prints", "record `os codemod --help; echo $?` from the same shell — NOT because the hint prescribes it (it prescribes nothing), but to corroborate the hint's factual claim: no `codemod` command may be registered, oclif exiting nonzero with 'command codemod not found'. A registered codemod would make 'No automated codemod ships with the CLI' the new false statement" ], "acceptance": [ { - "clause": "every seeded pattern is reported with file:line attribution matching the seeded lines — 8 registry entries, with the retired-lookup entry attributing BOTH its spellings (reference_filters and referenceFilters land on one regex, doctor.ts:1291)", + "clause": "every seeded pattern is reported with file:line attribution matching the seeded lines — 8 registry entries, with the retired-lookup entry attributing BOTH its spellings (reference_filters and referenceFilters land on one regex, doctor.ts)", "oracle": "log", - "verify": "each warning row reads ':' (doctor.ts:2252) and the file:line pairs match where the seeds were written; no seeded line is missing and no unseeded line is reported", + "verify": "each warning row reads ':' (doctor.ts) and the file:line pairs match where the seeds were written; no seeded line is missing and no unseeded line is reported", "evidence": "the seeded file with line numbers + the transcript rows" }, { "clause": "--verbose prints each hit's replacement prescription ('Use maxLength (camelCase)', 'Use lookupFilters … removed in #2377', …); the bare run withholds them", "oracle": "log", - "verify": "the dim '→ ' lines appear under --verbose only (doctor.ts:2253-2255 — the `if (flags.verbose)` guard and the dim line it wraps)", + "verify": "the dim '→ ' lines appear under --verbose only (doctor.ts — the `if (flags.verbose)` guard and the dim line it wraps)", "evidence": "the two transcripts diffed" }, { "clause": "the scan WARNS and never gates: a run with hits prints the warning rows and the count line yet exits 0 (hits feed hasWarnings only, never hasErrors), and the clean run prints the success line and exits 0 — both sides captured", "oracle": "log", - "verify": "echo $? is 0 for both the seeded and the clean run; the seeded run's summary is the '⚠️ Environment is functional' branch (doctor.ts:2250 sets hasWarnings inside the hits branch :2249-2288; exit 1 is reserved for error rows at :2302)", + "verify": "echo $? is 0 for both the seeded and the clean run; the seeded run's summary is the '⚠️ Environment is functional' branch (doctor.ts sets hasWarnings inside the hits branch; exit 1 is reserved for error rows)", "evidence": "both exit codes + summary lines" }, { - "clause": "decoys stay silent, each for its own documented reason: *.test.ts filtered (doctor.ts:1316), node_modules pruned by the walk (:1130 — the only anchor revision 2 quoted that did not move), and the .json file never visited because the walk is .ts-only (:1316) — the .json silence is recorded as a DOCUMENTED BOUNDARY of the scan (retired keys in JSON metadata are out of its reach), never as evidence the metadata is clean", + "clause": "decoys stay silent, each for its own documented reason: *.test.ts filtered (doctor.ts), node_modules pruned by the walk (— the only anchor revision 2 quoted that did not move), and the.json file never visited because the walk is.ts-only — the.json silence is recorded as a DOCUMENTED BOUNDARY of the scan (retired keys in JSON metadata are out of its reach), never as evidence the metadata is clean", "oracle": "log", "verify": "no transcript row names legacy.test.ts, node_modules, or meta.json; the run record states the .ts-only boundary explicitly", "evidence": "the transcript + the staged decoy listing" }, { - "clause": "absent-src honesty: with no src/ directory the scanner returns [] (doctor.ts:1314) and doctor prints the SAME success line a genuinely clean tree gets — so the success line alone is NOT evidence of cleanliness. The run must pair the line with proof src/ existed and was walked; a run record citing the line without that proof is the absence-inference false positive this clause exists to block", + "clause": "absent-src honesty: with no src/ directory the scanner returns [] (doctor.ts) and doctor prints the SAME success line a genuinely clean tree gets — so the success line alone is NOT evidence of cleanliness. The run must pair the line with proof src/ existed and was walked; a run record citing the line without that proof is the absence-inference false positive this clause exists to block", "oracle": "log", "verify": "the no-src run prints '✓ Deprecation scan No deprecated patterns found'; the run record annotates it as scanned-nothing, with `ls src` refusal captured alongside", "evidence": "the no-src transcript + the ls refusal" }, { - "clause": "the remediation hint prescribes NO command at all — it states that no automated codemod ships and routes the operator to the per-finding `→ replacement` instead. #10680 REMOVED the dead `objectstack codemod v2-to-v3` prescription rather than repointing it, and the print site's own comment (doctor.ts:2258-2282) records why a repoint would have been the same defect respelled: `os migrate meta` declines the src/** TypeScript rewrite by design ('unsafe and lossy'), three of the eight patterns (EnhancedObjectKernel and the two deep-import paths) are not metadata at all, and `os lint --fix` is print-only. This clause is the INVERSION of revision 2's expected-fail, and NOT into 'the hint now names a registered command' — that assertion was the wrong SHAPE for head, where the hint deliberately names none", + "clause": "the remediation hint prescribes NO command at all — it states that no automated codemod ships and routes the operator to the per-finding `→ replacement` instead. #10680 REMOVED the dead `objectstack codemod v2-to-v3` prescription rather than repointing it, and the print site's own comment (doctor.ts) records why a repoint would have been the same defect respelled: `os migrate meta` declines the src/** TypeScript rewrite by design ('unsafe and lossy'), three of the eight patterns (EnhancedObjectKernel and the two deep-import paths) are not metadata at all, and `os lint --fix` is print-only. This clause is the INVERSION of revision 2's expected-fail, and NOT into 'the hint now names a registered command' — that assertion was the wrong SHAPE for head, where the hint deliberately names none", "oracle": "log", - "verify": "the closing hint is the blue `ℹ` info line (printInfo, not a `⚠` row) and reads 'Found deprecated pattern(s). No automated codemod ships with the CLI — apply each finding’s replacement by hand (re-run with --verbose to print them).' in the bare run and '… — apply the → replacement shown under each finding above.' under --verbose (doctor.ts:2283-2288); no `os …`/`objectstack …` command name appears anywhere in the hint; and step 6's `os codemod --help` is STILL not-found — the hint's claim that no codemod ships is TRUE, which is what makes prescribing nothing the honest line rather than a second false one", + "verify": "the closing hint is the blue `ℹ` info line (printInfo, not a `⚠` row) and reads 'Found deprecated pattern(s). No automated codemod ships with the CLI — apply each finding’s replacement by hand (re-run with --verbose to print them).' in the bare run and '… — apply the → replacement shown under each finding above.' under --verbose (doctor.ts); no `os …`/`objectstack …` command name appears anywhere in the hint; and step 6's `os codemod --help` is STILL not-found — the hint's claim that no codemod ships is TRUE, which is what makes prescribing nothing the honest line rather than a second false one", "evidence": "both hint lines verbatim (bare and --verbose) + the `os codemod --help` not-found capture" } ], @@ -1037,18 +1037,18 @@ ], "traps": ["absence-inference", "stale-dist"], "source": [ - "packages/cli/src/commands/doctor.ts (flag :1793; scan block :2245-2292 with the scanDir = /src at :2247, the per-hit warning row at :2252, the --verbose `→ replacement` at :2253-2255, the #10680 reasoning comment at :2258-2282, the two-branch hint at :2283-2288 and the clean-run success line at :2290; DEPRECATED_PATTERNS :1262-1310 — retired-lookup pair :1291; scanDeprecatedPatterns :1312-1341 — absent-dir [] at :1314, .ts-only walk + .test.ts filter :1316; walkDir node_modules prune :1130)", + "packages/cli/src/commands/doctor.ts (flag; scan block with the scanDir = /src, the per-hit warning row, the --verbose `→ replacement`, the #10680 reasoning comment, the two-branch hint and the clean-run success line; DEPRECATED_PATTERNS — retired-lookup pair; scanDeprecatedPatterns — absent-dir [],.ts-only walk +.test.ts filter; walkDir node_modules prune)", "packages/cli/src/commands/doctor-deprecation-hint-commands.test.ts (#10680's pin — the class-closing sweep that every `os …` hint doctor can PRINT resolves under src/commands/, plus six end-to-end cases over Doctor.run. Added to source at revision 3 because revision 2's knownGaps asserted this surface had no automated coverage of any kind)", - "content/docs/protocol/backward-compatibility.mdx:126-134 (the documented workflow, including the :134 admission that the codemod command is not yet available)", + "content/docs/protocol/backward-compatibility.mdx (the documented workflow, including the admission that the codemod command is not yet available)", "packages/cli/README.md §os doctor (documents -v and --scan-deprecations)", "packages/create-objectstack/src/templates/blank/ (the scaffold whose src/ the seeds land in)", "#9591 (the v18 `os migrate meta --write` AST codemod, pm:on-hold — the in-place metadata codemod is COMMISSIONED, not shipped; cited so this item's remediation-path clause resolves to the card that owns it)", "sibling items cli.doctor-health-report (the command's own report contract) and cli.migrate-meta-codemod (`os migrate meta` — the remediation path that does exist; its revision 3 asserts the command LISTS mechanical edits and rewrites no source file. The id keeps its `-codemod` spelling only because ids are immutable and never reused (README lifecycle) — it reads as a forward reference to #9591, not as a description of today's command)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能), found independently by two hunters and re-verified against source: --scan-deprecations had zero coverage of any kind (no test in the repo references it). The 8-entry pattern table was recounted from doctor.ts:1186-1234; brief line-drift corrected during authoring (absent-dir return is :1238 not :1273, the scanner body ends at :1265 not :1276). The dead `objectstack codemod v2-to-v3` prescription at :2149 is encoded as an expected-fail clause with the docs' own 'not yet available' admission as corroborating source", "ref": "claude/new-session-0pv25p" }, - { "revision": 2, "date": "2026-08-21", "change": "corrected the two descriptors that called `os migrate meta` a codemod — the expected-fail clause's 'The real metadata codemod is os migrate meta' and the source list's 'cli.migrate-meta-codemod (the codemod that actually exists)'. It is not one. Re-derived at this base: the command declares exactly from/to/step/out/stored/database-url/apply/yes/force/type/json (commands/migrate/meta.ts:195-239) with no --write/--fix/in-place; its only file writes are the two `if (flags.out)`-guarded writeFileSync calls at :347 and :394-396, which emit a JSON snapshot; and its header at :155-157 states the command does not silently rewrite TS config source because that AST rewrite is 'unsafe and lossy'. Both descriptors now say what it is — a chain replayer that PRINTS the mechanical edits for hand application — and name #9591 (v18, pm:on-hold) as the commissioned-but-unbuilt in-place codemod, which was added to `source` so the reference resolves. The clause the item turns on is UNCHANGED and still an expected-fail: the hint at doctor.ts:2149 prescribes `objectstack codemod v2-to-v3`, registered nowhere. The clause also now warns that a FIX which merely repoints the hint at `os migrate meta` must describe it as a list, not an auto-fix — otherwise the dead prescription is replaced by a false one. Sibling cli.migrate-meta-codemod was read and deliberately left alone: its revision-3 text (#9733 via PR #10412) already states the print-only contract correctly, and its `-codemod` id is immutable by README lifecycle", "ref": "#10418" }, - { "revision": 3, "date": "2026-08-24", "change": "text refresh only, no capability change: #10680 landed the fix revision 2's expected-fail described, so acceptance[5] inverts — but NOT into 'the hint now names a registered command'. The route taken was prescribe-nothing: at this base the hint is a two-branch printInfo (doctor.ts:2283-2288) reading 'Found deprecated pattern(s). No automated codemod ships with the CLI — apply each finding’s replacement by hand (re-run with --verbose to print them).' bare and '… — apply the → replacement shown under each finding above.' under --verbose, so revision 2's assertion 'the hint must name a command os actually registers' was the wrong SHAPE for head, not a fail that merely flipped. The print site's comment at :2258-2282 records why a repoint at `os migrate meta` was refused, and the clause now cites that comment instead of re-deriving the migrate/meta.ts anchors. Six texts changed beyond the named clause, all re-derived at this base: (a) the title's trailing expected-fail phrase inverted; (b) fixtures.knownGaps — revision 2's 'no test anywhere in the repo references --scan-deprecations' is FALSE at head, #10680 added packages/cli/src/commands/doctor-deprecation-hint-commands.test.ts, and the gap text now states what that pin does and does NOT reach (no decoys, no absent-src probe, two seeded lines rather than eight patterns, in-process Doctor.run rather than the shipped binary — so stale-dist stays this item's to catch), with the test added to source; (c) step 6 repurposed from 'record the command the hint prescribes' into a corroboration probe — `os codemod --help` must STILL be not-found, which is what makes 'No automated codemod ships' true rather than the next false statement; (d) step 3 now demands the closing hint from BOTH runs, since it is not the same sentence; (e) three negatives added for the inverted clause's wrong shapes — an unregistered name returning, a repoint at the registered-but-incapable `os migrate meta`, and the count line vanishing with the prescription; (f) every doctor.ts anchor re-derived by content: #10679 moved the scanner block +76 and #10680's comment pushed the print site to +109, so of the eleven numbers revision 2 quoted only walkDir's node_modules prune at :1130 still held. Verified accurate at head and deliberately LEFT ALONE: content/docs/protocol/backward-compatibility.mdx:126-134 (the :134 'not yet available' admission still reads as quoted — and at head the tool is consistent WITH it), packages/cli/README.md §os doctor :212-215, the blank template's src/objects/note.object.ts, #9591 (still open, target:v18, pm:on-hold), sibling cli.migrate-meta-codemod (still revision 3), the DEPRECATED_PATTERNS count of 8, the four original negatives, both personas and both traps. Out of scope and filed instead: FOLLOW-UPS.md §7b still lists this prescription as unfixed and names `os migrate meta` as 'the real path', the repoint #10680 refused (#11628)", "ref": "#11420" } + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能), found independently by two hunters and re-verified against source: --scan-deprecations had zero coverage of any kind (no test in the repo references it). The 8-entry pattern table was recounted from doctor.ts; brief line-drift corrected during authoring (absent-dir return is not, the scanner body ends not). The dead `objectstack codemod v2-to-v3` prescription is encoded as an expected-fail clause with the docs' own 'not yet available' admission as corroborating source", "ref": "claude/new-session-0pv25p" }, + { "revision": 2, "date": "2026-08-21", "change": "corrected the two descriptors that called `os migrate meta` a codemod — the expected-fail clause's 'The real metadata codemod is os migrate meta' and the source list's 'cli.migrate-meta-codemod (the codemod that actually exists)'. It is not one. Re-derived at this base: the command declares exactly from/to/step/out/stored/database-url/apply/yes/force/type/json (commands/migrate/meta.ts) with no --write/--fix/in-place; its only file writes are the two `if (flags.out)`-guarded writeFileSync calls, which emit a JSON snapshot; and its header states the command does not silently rewrite TS config source because that AST rewrite is 'unsafe and lossy'. Both descriptors now say what it is — a chain replayer that PRINTS the mechanical edits for hand application — and name #9591 (v18, pm:on-hold) as the commissioned-but-unbuilt in-place codemod, which was added to `source` so the reference resolves. The clause the item turns on is UNCHANGED and still an expected-fail: the hint at doctor.ts prescribes `objectstack codemod v2-to-v3`, registered nowhere. The clause also now warns that a FIX which merely repoints the hint at `os migrate meta` must describe it as a list, not an auto-fix — otherwise the dead prescription is replaced by a false one. Sibling cli.migrate-meta-codemod was read and deliberately left alone: its revision-3 text (#9733 via PR #10412) already states the print-only contract correctly, and its `-codemod` id is immutable by README lifecycle", "ref": "#10418" }, + { "revision": 3, "date": "2026-08-24", "change": "text refresh only, no capability change: #10680 landed the fix revision 2's expected-fail described, so acceptance[5] inverts — but NOT into 'the hint now names a registered command'. The route taken was prescribe-nothing: at this base the hint is a two-branch printInfo (doctor.ts) reading 'Found deprecated pattern(s). No automated codemod ships with the CLI — apply each finding’s replacement by hand (re-run with --verbose to print them).' bare and '… — apply the → replacement shown under each finding above.' under --verbose, so revision 2's assertion 'the hint must name a command os actually registers' was the wrong SHAPE for head, not a fail that merely flipped. The print site's comment records why a repoint at `os migrate meta` was refused, and the clause now cites that comment instead of re-deriving the migrate/meta.ts anchors. Six texts changed beyond the named clause, all re-derived at this base: (a) the title's trailing expected-fail phrase inverted; (b) fixtures.knownGaps — revision 2's 'no test anywhere in the repo references --scan-deprecations' is FALSE at head, #10680 added packages/cli/src/commands/doctor-deprecation-hint-commands.test.ts, and the gap text now states what that pin does and does NOT reach (no decoys, no absent-src probe, two seeded lines rather than eight patterns, in-process Doctor.run rather than the shipped binary — so stale-dist stays this item's to catch), with the test added to source; (c) step 6 repurposed from 'record the command the hint prescribes' into a corroboration probe — `os codemod --help` must STILL be not-found, which is what makes 'No automated codemod ships' true rather than the next false statement; (d) step 3 now demands the closing hint from BOTH runs, since it is not the same sentence; (e) three negatives added for the inverted clause's wrong shapes — an unregistered name returning, a repoint at the registered-but-incapable `os migrate meta`, and the count line vanishing with the prescription; (f) every doctor.ts anchor re-derived by content: #10679 moved the scanner block +76 and #10680's comment pushed the print site to +109, so of the eleven numbers revision 2 quoted only walkDir's node_modules prune still held. Verified accurate at head and deliberately LEFT ALONE: content/docs/protocol/backward-compatibility.mdx (the 'not yet available' admission still reads as quoted — and at head the tool is consistent WITH it), packages/cli/README.md §os doctor, the blank template's src/objects/note.object.ts, #9591 (still open, target:v18, pm:on-hold), sibling cli.migrate-meta-codemod (still revision 3), the DEPRECATED_PATTERNS count of 8, the four original negatives, both personas and both traps. Out of scope and filed instead: FOLLOW-UPS.md §7b still lists this prescription as unfixed and names `os migrate meta` as 'the real path', the repoint #10680 refused (#11628)", "ref": "#11420" } ] }, { @@ -1092,11 +1092,11 @@ { "clause": "the ruled definition of duplicate holds (#8928 point 2): the value held in two COALESCE(organization_id,'__global__') partitions IS reported; the value repeated WITHIN one partition is NOT — the partitioned unique index owns that defect", "oracle": "log", - "verify": "the cross-partition seed appears in .duplicates with both partitions listed; the within-partition seed appears nowhere in .duplicates (the probe demands COUNT(*) > 1 AND COUNT(DISTINCT partition) > 1, duplicates.ts:263-266)", + "verify": "the cross-partition seed appears in.duplicates with both partitions listed; the within-partition seed appears nowhere in.duplicates (the probe demands COUNT(*) > 1 AND COUNT(DISTINCT partition) > 1, duplicates.ts)", "evidence": "the seeded values vs the report's duplicates array" }, { - "clause": "read-only in fact, not just in intent: the DB file is byte-identical before and after a full run — no DDL, no seed, no row written (deferSchemaDdl + readOnlyProbe boot, duplicates.ts:670-679)", + "clause": "read-only in fact, not just in intent: the DB file is byte-identical before and after a full run — no DDL, no seed, no row written (deferSchemaDdl + readOnlyProbe boot, duplicates.ts)", "oracle": "log", "verify": "the two md5sums match; duplicates.pre-repair.test.ts pins the same invariant down to _objectstack_sequences", "evidence": "the md5 pair" @@ -1128,7 +1128,7 @@ { "clause": "a driver with no raw-SQL seam refuses LOUDLY: {error: 'no_sql_seam', …} with exit 1 — an empty clean report from a driver the probe cannot run against would be indistinguishable from 'never looked'", "oracle": "log", - "verify": "the memory-driver run emits the no_sql_seam payload and echo $? is 1 (duplicates.ts:787-812); a boot failure likewise answers {error: 'boot_failed'} exit 1, never a zero-duplicate success", + "verify": "the memory-driver run emits the no_sql_seam payload and echo $? is 1 (duplicates.ts); a boot failure likewise answers {error: 'boot_failed'} exit 1, never a zero-duplicate success", "evidence": "the refusal payload + exit code" } ], @@ -1140,13 +1140,13 @@ ], "traps": ["stale-dist", "absence-inference"], "source": [ - "packages/cli/src/commands/migrate/duplicates.ts (the :17-80 contract header encoding the 2026-08-16 maintainer ruling's five points; report interfaces :82-165; the cross-partition HAVING at :263-266; the seam-answer guards at :434-523; flags :739-747; read-only boot :761-770; no_sql_seam refusal :787-812)", + "packages/cli/src/commands/migrate/duplicates.ts (the contract header encoding the 2026-08-16 maintainer ruling's five points; report interfaces; the cross-partition HAVING; the seam-answer guards; flags; read-only boot; no_sql_seam refusal)", "packages/cli/src/commands/migrate/duplicates.contract.test.ts (the full JSON shape against a real sqlite), duplicates.pre-repair.test.ts (byte-identical DB + the #8686 repair measured destroying the evidence), duplicates.integration.test.ts, duplicates.probe-sql.test.ts — seam pins; none drives the oclif command end-to-end, hence no automated entry", "#8928 (the card and ruling), #8686 / #8844 (the closed producers whose damage this inventories)", "sibling item cli.migrate-plan-apply-json (lists duplicates as a variant; the scratch-DB boot recipe is shared)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os migrate duplicates` landed 2026-08-16 (#8928) after the sibling migrate item's enumeration was authored, so the subcommand had no functional coverage. Authored from the :17-80 contract header's five ruling points, with the perishability ordering (seed → scan → only then any repair-bearing boot) carried as a load-bearing knownGap and the no-JSON-flag posture spelled out so the #4873 sweep does not misread an oclif 2", "ref": "claude/new-session-0pv25p" }, + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os migrate duplicates` landed 2026-08-16 (#8928) after the sibling migrate item's enumeration was authored, so the subcommand had no functional coverage. Authored from the contract header's five ruling points, with the perishability ordering (seed → scan → only then any repair-bearing boot) carried as a load-bearing knownGap and the no-JSON-flag posture spelled out so the #4873 sweep does not misread an oclif 2", "ref": "claude/new-session-0pv25p" }, { "revision": 2, "date": "2026-08-21", "change": "clause-7 knownGap was factually wrong about the memory driver, and the run that trusted it produced a false all-clear: the driver exposes a NO-OP `execute` seam (warn + return null), not no seam, so the no_sql_seam refusal never fired and the scan answered exit 0 with `duplicates:[]` — indistinguishable from `never looked`. Corrected the knownGap, and re-derived the drifted duplicates.ts line citations against the fix that makes the refusal live (#10677). The mongodb half is left explicitly UNVERIFIED rather than restated: that driver was not loaded for the fix", "ref": "#10677" }, { "revision": 3, "date": "2026-08-22", "change": "the #11031 revision bump landed the kernel:ready pre-flight (#8725) without this entry: the report contract moved reportVersion 1 → 2, gaining `runtimeIndexPreflight[]` plus `summary.runtimeIndexesBlocked` and `summary.runtimeIndexBlockingRows`; the jq walk step now includes `.runtimeIndexPreflight`; a seeding step was added for the `kernel:ready` blocker (two ACTIVE `sys_view_definition` rows sharing a name with `organization_id` and `owner` both NULL); and a new acceptance clause covers the kernel:ready pre-flight — one entry per index the three `kernel:ready` migrations tighten (four, the overlay migration owns two), each `blocked`/`clear`/`table-absent`/`unreadable`, with the matched control written into its verify step (the same database run through `os migrate plan` names neither the index nor the view). This entry repairs that ledger gap; no further change to the item's fields", "ref": "#11031" } ] @@ -1164,7 +1164,7 @@ "app": "showcase", "requires": [ "a booted showcase (`pnpm dev -- --fresh -p `) — it ships the external datasource fixture: examples/app-showcase/src/system/datasources/showcase-external.datasource.ts (name 'showcase_external', sqlite file .objectstack/data/showcase_external.db) seeded by external-fixture.ts", - "an admin session token minted via POST /api/v1/auth/sign-in/email for a user HOLDING manage_platform_settings — authentication alone is NOT enough since #9901/#10255: every route these three commands address is a federation READ (list-tables → GET /external/tables, introspect → POST /external/tables/:remote/draft, validate → POST /external/validate), and each opens with refuseFederationRequest, which answers 401 UNAUTHENTICATED with no identity and then 403 PERMISSION_DENIED naming FEDERATION_READ_CAPABILITY = manage_platform_settings (external-datasource-routes.ts:157, :287-324; pinned in external-datasource-routes-auth-guard.test.ts). Pass it as --token / OS_TOKEN, with --url / OS_CLOUD_URL pointing at the boot port", + "an admin session token minted via POST /api/v1/auth/sign-in/email for a user HOLDING manage_platform_settings — authentication alone is NOT enough since #9901/#10255: every route these three commands address is a federation READ (list-tables → GET /external/tables, introspect → POST /external/tables/:remote/draft, validate → POST /external/validate), and each opens with refuseFederationRequest, which answers 401 UNAUTHENTICATED with no identity and then 403 PERMISSION_DENIED naming FEDERATION_READ_CAPABILITY = manage_platform_settings (external-datasource-routes.ts; pinned in external-datasource-routes-auth-guard.test.ts). Pass it as --token / OS_TOKEN, with --url / OS_CLOUD_URL pointing at the boot port", "a scratch objects/ dir in the invocation cwd for the --out draft, and a scratch config to wire the draft into for the compile clause" ], "knownGaps": [ @@ -1185,19 +1185,19 @@ { "clause": "list-tables prints the remote tables with schema qualification and column counts (plus row estimates when the driver reports them) through GET /api/v1/datasources/:name/external/tables, and --schema narrows via the ?schema= query — this item pins the CLI's /external/tables FEDERATION door specifically; the admin twin GET /:name/remote-tables is a different mount owned by the integration-system area", "oracle": "log", - "verify": "the listing matches the fixture DB's actual tables (cross-check with sqlite3 .tables on showcase_external.db); the schema filter narrows and the no-match run prints 'No remote tables found.' (list-tables.ts:38-57; route registered in packages/rest/src/external-datasource-routes.ts, ledgered at rest-route-ledger.ts:357)", + "verify": "the listing matches the fixture DB's actual tables (cross-check with sqlite3.tables on showcase_external.db); the schema filter narrows and the no-match run prints 'No remote tables found.' (list-tables.ts; route registered in packages/rest/src/external-datasource-routes.ts, ledgered at rest-route-ledger.ts)", "evidence": "the listings + the sqlite3 cross-check" }, { "clause": "introspect generates an Object draft from the remote table (POST …/external/tables/:remote/draft) — stdout by default, --out writes the file and echoes the path, and draft.review notes surface as REVIEW: warnings rather than being dropped", "oracle": "log", - "verify": "the draft source names the remote table's columns; the --out run writes exactly objects/qa_external.object.ts; review notes (if the fixture produces any) print as 'REVIEW: column … — …' (introspect.ts:42-83)", + "verify": "the draft source names the remote table's columns; the --out run writes exactly objects/qa_external.object.ts; review notes (if the fixture produces any) print as 'REVIEW: column … — …' (introspect.ts)", "evidence": "the draft + the written file + any REVIEW lines" }, { "clause": "the --out jail holds BOTH ways: an absolute path and a cwd-escaping relative path are each refused with '--out must be a relative path within the current directory', exit nonzero, and NO file is written — the body is server-generated TypeScript, so the jail is a security boundary against a compromised server, not a convenience check", "oracle": "log", - "verify": "both escape probes print the worded refusal (introspect.ts:65-74 — resolve() against cwd, isAbsolute + prefix check) with nonzero exit, and ls confirms neither target exists", + "verify": "both escape probes print the worded refusal (introspect.ts — resolve() against cwd, isAbsolute + prefix check) with nonzero exit, and ls confirms neither target exists", "evidence": "both refusals + exit codes + the absence listing" }, { @@ -1209,13 +1209,13 @@ { "clause": "validate is the drift detector with an honest exit: matching objects print '✓ matches'; a mismatch prints per-diff rows (kind, column, expected vs actual, ✗ for error / ⚠ for warning) and the run exits 1 exactly when an error-severity diff exists", "oracle": "log", - "verify": "the untouched run is all-✓ exit 0; the staged-drift run prints the ✗ row naming the renamed column and echo $? is 1 (validate.ts:59-74 — hasError gates this.error(…, {exit: 1}))", + "verify": "the untouched run is all-✓ exit 0; the staged-drift run prints the ✗ row naming the renamed column and echo $? is 1 (validate.ts — hasError gates this.error(…, {exit: 1}))", "evidence": "both transcripts + exit codes" }, { "clause": "the server's error arm is honored on all three commands: a body.error (unknown datasource, driver failure) becomes this.error — a printed error WITH a nonzero exit, never a 0", "oracle": "log", - "verify": "the unknown-name probes each exit nonzero with the server's own error text (list-tables.ts:46, introspect.ts:57, validate.ts:51)", + "verify": "the unknown-name probes each exit nonzero with the server's own error text (list-tables.ts, introspect.ts, validate.ts)", "evidence": "the three error captures + exit codes" } ], @@ -1228,14 +1228,14 @@ ], "traps": ["dispatcher-vs-hono-route", "seed-data-thin", "wrong-persona"], "source": [ - "packages/cli/src/commands/datasource/list-tables.ts (:12-15 route doc, :38-57 listing), introspect.ts (:14-18 route doc, :31-36 flags, :65-74 the cwd jail, :81-83 REVIEW passthrough), validate.ts (:12-16 route doc, :59-74 diff rendering + exit)", - "packages/rest/src/external-datasource-routes.ts + rest-route-ledger.ts:357-361 (the served federation routes incl. POST …/external/validate) and external-datasource-routes-auth-guard.test.ts (the auth wall the token satisfies)", + "packages/cli/src/commands/datasource/list-tables.ts (route doc, listing), introspect.ts (route doc, flags, the cwd jail, REVIEW passthrough), validate.ts (route doc, diff rendering + exit)", + "packages/rest/src/external-datasource-routes.ts + rest-route-ledger.ts (the served federation routes incl. POST …/external/validate) and external-datasource-routes-auth-guard.test.ts (the auth wall the token satisfies)", "examples/app-showcase/src/system/datasources/showcase-external.datasource.ts + external-fixture.ts (the fixture datasource 'showcase_external')", "ADR-0015 (external datasource federation)", "sibling item cli.flag-command-error-ux (owned only the topic's --help until now)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): the datasource topic's three subcommands had only a --help variant, no functional coverage. Re-verified against source during authoring: the hunter brief asked whether an --out escape refusal exists — it DOES (introspect.ts:65-74, absolute + traversal both refused before any write), so the jail is asserted positively with both escape probes rather than worded observe-and-flag. The /external/tables-vs-/remote-tables mount split is recorded as a runner trap with the admin twin explicitly routed to the integration-system area", "ref": "claude/new-session-0pv25p" }, + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): the datasource topic's three subcommands had only a --help variant, no functional coverage. Re-verified against source during authoring: the hunter brief asked whether an --out escape refusal exists — it DOES (introspect.ts, absolute + traversal both refused before any write), so the jail is asserted positively with both escape probes rather than worded observe-and-flag. The /external/tables-vs-/remote-tables mount split is recorded as a runner trap with the admin twin explicitly routed to the integration-system area", "ref": "claude/new-session-0pv25p" }, { "revision": 2, "date": "2026-08-24", "change": "spell the CAPABILITY persona out. Read in the same pass as #11138 and confirmed rather than assumed: this item was authored 2026-08-20 against the pre-#9901 federation door, so its fixture asked only for \"an admin session token\" behind \"the auth guard\" — the #9686 AUTHENTICATION floor. At head all three commands address federation READ routes (list-tables GET /external/tables, introspect POST /external/tables/:remote/draft, validate POST /external/validate), each gated on FEDERATION_READ_CAPABILITY = manage_platform_settings by #9901/#10255, so a merely-authenticated token 403s every clause in the item. The capability is now named in both personas and in the token fixture, and the resulting all-403 run is recorded as a negative (the wrong-persona trap this item already declares) so a runner re-mints instead of filing the landed gate as a CLI regression", "ref": "#11138" } ] }, @@ -1255,7 +1255,7 @@ "jq over dist/objectstack.json — body presence and body.capabilities per hook are the artifact-side oracle" ], "knownGaps": [ - "the RUNTIME half of every token (the sandbox rejecting an undeclared call, log routing) is records-forms territory (its L2 sandbox item cites body-runner.ts:321) — this item owns the BUILD-side gate and inference only; do not re-prove the sandbox here", + "the RUNTIME half of every token (the sandbox rejecting an undeclared call, log routing) is records-forms territory (its L2 sandbox item cites body-runner.ts) — this item owns the BUILD-side gate and inference only; do not re-prove the sandbox here", "bodyExtractionWarnings are invisible on a default build (see the expected-fail clause), so the default-path evidence must come from the artifact (body absent, bundle emitted), not from output that will not appear" ] }, @@ -1271,41 +1271,41 @@ { "clause": "--strict-body refuses EACH forbidden pattern with that pattern's own worded reason — fetch and import prescribe 'declare a Connector recipe instead', require/process/globalThis/eval/new Function each name themselves — never a bare parse error, and the run exits 1 (the --json arm emits the issues payload then exits 1)", "oracle": "build", - "verify": "the seven diagnostics each quote the matching FORBIDDEN_PATTERNS reason (extract-hook-body.ts:33-41) under the '--strict-body: N callable(s) lack a metadata body' error (compile.ts:126-149); echo $? is 1 on both the human and --json paths", + "verify": "the seven diagnostics each quote the matching FORBIDDEN_PATTERNS reason (extract-hook-body.ts) under the '--strict-body: N callable(s) lack a metadata body' error (compile.ts); echo $? is 1 on both the human and --json paths", "evidence": "the seven verbatim diagnostics + exit codes + the --json payload" }, { "clause": "read/write inference from the artifact: .object(x).find → api.read and .object(x).update → api.write land in body.capabilities — and the const-api ALIAS is still caught, because the regex deliberately matches any chain ending in .object(…). (over-inclusive by design; a false-positive token is rejected at runtime by the sandbox, not silently honored at build)", "oracle": "build", - "verify": "jq shows ['api.read'] on the find hook, ['api.write'] on the update hook, and ['api.read'] on the alias hook (CAPABILITY_PATTERNS at extract-hook-body.ts:49-50 with the :44-48 rationale)", + "verify": "jq shows ['api.read'] on the find hook, ['api.write'] on the update hook, and ['api.read'] on the alias hook (CAPABILITY_PATTERNS at extract-hook-body.ts with the rationale)", "evidence": "the jq captures per hook" }, { "clause": "ctx.crypto.randomUUID → crypto.uuid and ctx.log. → log are inferred; and the #4391 regression clause: ctx.crypto.hash infers NOTHING — the crypto.hash token was removed because the sandbox never installed the function, and inferring a capability from a call that always threw is what let os build bless a dead body", "oracle": "build", - "verify": "jq shows ['crypto.uuid'] and ['log'] on their hooks, and the hash-only hook's body.capabilities is [] — any crypto token there is the pinned regression (extract-hook-body.ts:51-56, the :52-55 removal note)", + "verify": "jq shows ['crypto.uuid'] and ['log'] on their hooks, and the hash-only hook's body.capabilities is [] — any crypto token there is the pinned regression (extract-hook-body.ts, the removal note)", "evidence": "the three jq captures" }, { "clause": "capabilities the body's own code does not reveal are declared as DATA: a hook supplying body: { language, source, capabilities: [...] } ships exactly those tokens into the artifact. The RETIRED @capabilities first-line comment (#10917, ADR-0049 enforce-or-remove) adds nothing — a config that still carries one builds identically to one that does not, which is the guarantee owed to apps authored while it was documented", "oracle": "build", - "verify": "jq shows the declared tokens verbatim on the body-supplying hook; the retired-comment hook carries ONLY what inference derives (['api.read'] for a body reading .object(x).find(…) — never the api.write/log the comment names) and os build exits 0 saying nothing about it. No override branch is left in extract-hook-body.ts to read the comment (retirement note :43-59); content/docs/automation/hook-bodies.mdx documents the removal, not the spelling", + "verify": "jq shows the declared tokens verbatim on the body-supplying hook; the retired-comment hook carries ONLY what inference derives (['api.read'] for a body reading.object(x).find(…) — never the api.write/log the comment names) and os build exits 0 saying nothing about it. No override branch is left in extract-hook-body.ts to read the comment (retirement note); content/docs/automation/hook-bodies.mdx documents the removal, not the spelling", "evidence": "both jq captures + the exit code" }, { "clause": "#1876 self-containment: a handler referencing a module-scope identifier throws out of extraction naming the identifier(s), the caller catches and keeps the handler BUNDLED (no body, handler ref into the .mjs) so the default build stays green with no behavior change — while --strict-body surfaces the same message as a hard failure", "oracle": "build", - "verify": "default run: exit 0, jq shows no body on that hook, the runtime bundle is emitted; strict run: exit 1 with 'references identifier(s) not in scope at runtime: ' (extract-hook-body.ts:94-109; the catch-and-bundle at lower-callables.ts:63-78)", + "verify": "default run: exit 0, jq shows no body on that hook, the runtime bundle is emitted; strict run: exit 1 with 'references identifier(s) not in scope at runtime: ' (extract-hook-body.ts; the catch-and-bundle at lower-callables.ts)", "evidence": "both runs' exits + the jq capture + the strict diagnostic" }, { "clause": "an all-body-only config skips the legacy bundle: the skip line prints, no objectstack-runtime.*.mjs lands in dist/, and previously emitted bundles are cleaned — the artifact is a single self-describing JSON", "oracle": "build", - "verify": "the 'Skipping legacy runtime bundle' line (compile.ts:390-393) + an empty dist/ glob for the .mjs after a run that previously emitted one", + "verify": "the 'Skipping legacy runtime bundle' line (compile.ts) + an empty dist/ glob for the.mjs after a run that previously emitted one", "evidence": "the line + the dist listing" }, { - "clause": "EXPECTED-FAIL (contract contradiction — record actual behavior): on a DEFAULT `os build`, a forbidden-pattern hook ships SILENTLY via the .mjs bundle — the extraction failure is caught (lower-callables.ts:63-78), the warning is recorded in bodyExtractionWarnings but printed NOWHERE on the default path and excluded from the --json success payload (whose warnings key carries rule advisories only, compile.ts:437) — while the extractor's own contract header promises the build FAILS with 'no silent fallback to the L3 .mjs path because that path is being closed' (extract-hook-body.ts:14-18). hook-bodies.mdx:256 documents the warn-and-bundle default, so the DOCS and the CODE agree with each other and both contradict the extractor's stated contract. The clause: a forbidden pattern on the default path must at least SURFACE its warning; today the only observable trace is the bundle's existence", + "clause": "EXPECTED-FAIL (contract contradiction — record actual behavior): on a DEFAULT `os build`, a forbidden-pattern hook ships SILENTLY via the.mjs bundle — the extraction failure is caught (lower-callables.ts), the warning is recorded in bodyExtractionWarnings but printed NOWHERE on the default path and excluded from the --json success payload (whose warnings key carries rule advisories only, compile.ts) — while the extractor's own contract header promises the build FAILS with 'no silent fallback to the L3.mjs path because that path is being closed' (extract-hook-body.ts). hook-bodies.mdx documents the warn-and-bundle default, so the DOCS and the CODE agree with each other and both contradict the extractor's stated contract. The clause: a forbidden pattern on the default path must at least SURFACE its warning; today the only observable trace is the bundle's existence", "oracle": "build", "verify": "the default run over the forbidden seven exits 0 with zero extraction-related output — grep the transcript and the --json payload for any of the seven reasons (expect none) while jq confirms all seven hooks are body-less and the bundle was emitted; the clause FAILS on that silence and flips when the warnings surface (or the header's fail-the-build contract is actually enforced)", "evidence": "the silent transcript + --json payload + the jq body-absence sweep" @@ -1320,15 +1320,15 @@ ], "traps": ["stale-dist", "absence-inference"], "source": [ - "packages/cli/src/utils/extract-hook-body.ts (contract header :4-67, carrying the #10917 retirement note at :43-59; FORBIDDEN_PATTERNS :71-88; CAPABILITY_PATTERNS :90-104 with the #4391 removal note :99-102; #1876 free-identifier throw :146-161 — the @capabilities override branch this item used to cite is retired and gone)", - "packages/cli/src/utils/lower-callables.ts (:33-36 the warnings field, :63-78 tryExtractBody's catch-all fallback-to-bundle — the seam the default path's silence flows from)", - "packages/cli/src/commands/compile.ts (:126-149 the --strict-body gate, :366-393 the needsBundle decision + skip line, :437 the --json warnings key that excludes extraction warnings)", + "packages/cli/src/utils/extract-hook-body.ts (contract header, carrying the #10917 retirement note; FORBIDDEN_PATTERNS; CAPABILITY_PATTERNS with the #4391 removal note; #1876 free-identifier throw — the @capabilities override branch this item used to cite is retired and gone)", + "packages/cli/src/utils/lower-callables.ts (the warnings field, tryExtractBody's catch-all fallback-to-bundle — the seam the default path's silence flows from)", + "packages/cli/src/commands/compile.ts (the --strict-body gate, the needsBundle decision + skip line, the --json warnings key that excludes extraction warnings)", "content/docs/automation/hook-bodies.mdx (the documented default + --strict-body posture; the Capability inference section — the inference table, the body.capabilities route, and the callout recording the retired directive)", "packages/cli/src/utils/lower-callables.test.ts (the existing unit seam pin — cited, not a substitute for driving os build)", "sibling items records-forms (runtime sandbox side, body-runner.ts) and cli.build-own-contract (the build's own exit/output contract)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts:63-78 catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts:126-149) produces the worded refusals; hook-bodies.mdx:256 documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" }, + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts) produces the worded refusals; hook-bodies.mdx documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" }, { "revision": 2, "date": "2026-08-22", "change": "the '// @capabilities' first-line override was RETIRED (#10917, maintainer ruling under ADR-0049 enforce-or-remove): measured on all four ordinary authoring shapes (.ts/.js/.mjs/an imported handler) it reached the extractor from NONE of them, so a documented directive silently taught a wrong convention and the mismatch surfaced far from its cause as a sandbox refusal. Its acceptance clause asserted behaviour that no longer exists — a runner would have filed a FAIL against the ruling — and is replaced by the covered route (body.capabilities as data) plus the inertness guarantee owed to configs that still carry the comment. Steps, fixtures and the extract-hook-body.ts line citations moved with it. No other clause changed; note the expected-fail clause still describes the pre-#10678 silent default and is stale on its own account, which is NOT this revision's subject", "ref": "claude/issue-10917-retire-capabilities-hook-directive" } ] }, @@ -1366,43 +1366,43 @@ { "clause": "the severity vocabulary is closed at error | warning | suggestion: every reported issue carries one of the three, the human report groups by them with ✗/⚠/ℹ, and each issue names its rule and path", "oracle": "log", - "verify": "every issues[].severity in the --json payloads is one of the three (the Severity type, lint.ts:30); the human sections match (lint.ts:577-615)", + "verify": "every issues[].severity in the --json payloads is one of the three (the Severity type, lint.ts); the human sections match (lint.ts)", "evidence": "the payloads + the grouped transcript" }, { "clause": "exit contract: 1 exactly when errors > 0 — warnings and suggestions NEVER flip it, on either path (--json's exit slot is errors.length > 0 ? 1 : 0 through CliExitCode; the human path exits via process.exit(1) only under errors) — and duration reports INSIDE the --json payload, never in the exit slot (the #4873 class; format.ts's own doc names os lint as the command that does this correctly)", "oracle": "log", - "verify": "the error-bearing runs exit 1, the warnings-only runs exit 0, on both paths (lint.ts:543-553 and :634); the payload carries a numeric duration key and the exit is exactly 0 or 1, stable across a repeat run", + "verify": "the error-bearing runs exit 1, the warnings-only runs exit 0, on both paths (lint.ts); the payload carries a numeric duration key and the exit is exactly 0 or 1, stable across a repeat run", "evidence": "the four exit codes + the payload's duration key + one repeat" }, { "clause": "--json emits the DECLARED lint shape: { passed, total, errors, warnings, suggestions, hiddenPlatform?, score?, issues: [{severity, rule, message, path, fix?}], duration } with the counters reconciling against issues[] — note this is lint's OWN shape, not os validate's (the #3782 build/validate parity class does not extend here; the parity lint owes is the registry clause below)", "oracle": "log", - "verify": "jq walks the declared keys; recomputed per-severity counts from issues[] equal the counters; passed === (errors === 0) (lint.ts:539-554)", + "verify": "jq walks the declared keys; recomputed per-severity counts from issues[] equal the counters; passed === (errors === 0) (lint.ts)", "evidence": "the payload + the jq reconciliation" }, { "clause": "#4409 registry parity: os lint runs the shared authoring-rule registry as its single call site, so it cannot disagree with os build about registry rules in either direction (the pre-registry lint returned clean for stacks build rejects AND rejected stacks build ships) — the staged broken-approver finding appears in BOTH commands' output with its rule id, and registry 'info' findings map to lint's 'suggestion' tier", "oracle": "log", - "verify": "the broken-approver rule id appears in the lint output AND the build refusal on the same config (runAuthoringRules('lint', …) at lint.ts:428-436 with the :407-427 rationale; build's side is cli.build-own-contract clause 3); the info→suggestion mapping is :430", + "verify": "the broken-approver rule id appears in the lint output AND the build refusal on the same config (runAuthoringRules('lint', …) at lint.ts with the rationale; build's side is cli.build-own-contract clause 3); the info→suggestion mapping is", "evidence": "the paired lint/build transcripts" }, { "clause": "the i18n fold separates platform from app findings honestly: metadataForm (platform-registry) coverage issues are hidden by default BUT counted — the dim disclosure line names how many were hidden and how to see them — --include-platform surfaces them as i18n/missing-metadataForm issues at translations.. paths, and --skip-i18n removes the fold entirely; hiding must never be silent (the 15.1 third-party eval drowned 848 user findings in platform noise, which is why the fold exists)", "oracle": "log", - "verify": "the bare run's hiddenPlatform count (payload) and disclosure line (human) match the --include-platform run's surfaced metadataForm issue count; the --skip-i18n payload carries no i18n/* rules (foldCoverageIssues at lint.ts:47-66; disclosure :559-567)", + "verify": "the bare run's hiddenPlatform count (payload) and disclosure line (human) match the --include-platform run's surfaced metadataForm issue count; the --skip-i18n payload carries no i18n/* rules (foldCoverageIssues at lint.ts; disclosure)", "evidence": "the three runs' payloads/transcripts diffed" }, { "clause": "--fix is a dry run in fact: the '→ fix:' suggestions print, the 'no files were modified' line prints, and the config file is byte-identical after", "oracle": "log", - "verify": "the md5 pair matches (flag help says dry-run, lint.ts:452; the mode line :627-630)", + "verify": "the md5 pair matches (flag help says dry-run, lint.ts; the mode line)", "evidence": "the md5 pair + the transcript" }, { "clause": "a config that fails to load exits 1 on both paths, and the --json path still emits one parseable {error} document — never a stack trace masquerading as a payload, never exit 0", "oracle": "log", - "verify": "both throwing-config runs exit 1; the --json output parses as a single JSON document carrying the error message (lint.ts:636-645)", + "verify": "both throwing-config runs exit 1; the --json output parses as a single JSON document carrying the error message (lint.ts)", "evidence": "the captures + exit codes" } ], @@ -1411,17 +1411,17 @@ "platform i18n findings silently dropped WITHOUT the hiddenPlatform disclosure is dishonest hiding — the count is what keeps the fold auditable", "--fix writing to any file is a dry-run contract violation", "a lint that disagrees with os build about a #4409 registry rule in either direction is the pre-registry defect the shared table exists to prevent — re-verify with the paired runs before filing which side is wrong", - "the hunter brief expected validate-shaped --json parity here; source shows lint's own shape (lint.ts:543-553) — a runner asserting validate's keys against lint is a checklist error, not a product finding" + "the hunter brief expected validate-shaped --json parity here; source shows lint's own shape (lint.ts) — a runner asserting validate's keys against lint is a checklist error, not a product finding" ], "traps": ["stale-dist", "seed-data-thin"], "source": [ - "packages/cli/src/commands/lint.ts (Severity :30; foldCoverageIssues :47-66; lintConfig's own rubric + the #4409 registry call :407-436 with info→suggestion :430; flags :450-478; --json emit :539-554; disclosure :559-567; human exit :634; load-failure catch :636-645; --fix dry-run :452/:627-630)", + "packages/cli/src/commands/lint.ts (Severity; foldCoverageIssues; lintConfig's own rubric + the #4409 registry call with info→suggestion; flags; --json emit; disclosure; human exit; load-failure catch; --fix dry-run)", "packages/cli/src/utils/collect-docs.ts (the ADR-0046 docs lint folded into the same issues stream)", - "packages/cli/src/utils/format.ts (CliExitCode :41 and the #4873 doc note naming os lint as the duration-inside-the-payload exemplar)", + "packages/cli/src/utils/format.ts (CliExitCode and the #4873 doc note naming os lint as the duration-inside-the-payload exemplar)", "sibling item cli.build-own-contract (clause 3 is the build side of the registry-parity pair; its #3782 --json parity clause binds build↔validate, NOT lint)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能), authored VERIFY-FIRST as the brief required since the hunter had lower confidence here. Source confirmed the three-severity split and errors-only gating; two sketch claims were corrected against lint.ts before authoring: (1) the --json shape is lint's own {passed,total,errors,warnings,suggestions,issues,duration} (:543-553), not the os validate #3782 parity shape — the parity lint actually owes is the #4409 shared-registry agreement with os build (:407-436), encoded as its own clause; (2) the exit contract is 0/1 on both paths (process.exit(1) at :634, CliExitCode slot at :553), not build's human-path oclif-2 split. The i18n platform fold (:47-66) with its hiddenPlatform disclosure is covered as the third load-bearing surface", "ref": "claude/new-session-0pv25p" } + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能), authored VERIFY-FIRST as the brief required since the hunter had lower confidence here. Source confirmed the three-severity split and errors-only gating; two sketch claims were corrected against lint.ts before authoring: (1) the --json shape is lint's own {passed,total,errors,warnings,suggestions,issues,duration}, not the os validate #3782 parity shape — the parity lint actually owes is the #4409 shared-registry agreement with os build, encoded as its own clause; (2) the exit contract is 0/1 on both paths (process.exit(1), CliExitCode slot), not build's human-path oclif-2 split. The i18n platform fold with its hiddenPlatform disclosure is covered as the third load-bearing surface", "ref": "claude/new-session-0pv25p" } ] }, { @@ -1444,7 +1444,7 @@ ], "knownGaps": [ "os plugin publish CANNOT complete locally: after extracting the artifact manifest it requires a cloud token and posts to /api/v1/cloud/packages. Only its PRE-NETWORK legs are scoreable here — artifact discovery in cwd, manifest extraction, the id/version refusal, and the unsigned-artifact notice. Record blocked(environment) for anything past the auth step; the cloud control plane is not part of this checklist's fixture set", - "the SERVER-side mirror of the identity refusal — POST /api/v1/packages/publish answering 400 PACKAGE_MANIFEST_INVALID when id or version is missing (packages/rest/src/package-routes.ts:532) — is deliberately not scored here: it needs a deployment composing the `package` service, and one without it answers 404 with the 'composes no `package` service' message, which is not a failure of anything. Score it from the api lane if a marketplace-capable deployment is available", + "the SERVER-side mirror of the identity refusal — POST /api/v1/packages/publish answering 400 PACKAGE_MANIFEST_INVALID when id or version is missing (packages/rest/src/package-routes.ts) — is deliberately not scored here: it needs a deployment composing the `package` service, and one without it answers 404 with the 'composes no `package` service' message, which is not a failure of anything. Score it from the api lane if a marketplace-capable deployment is available", "the artifact is a gzipped ustar with mtime pinned to 0 and entries emitted in sorted path order, so identical inputs produce byte-identical archives — that is what makes the two packaging builds comparable, and it is also why a stale artifact from an earlier run is indistinguishable from a fresh one by name alone. Build each mode into its own --out path" ] }, @@ -1502,20 +1502,20 @@ "destructive-in-place" ], "source": [ - "packages/cli/src/commands/plugin/build.ts — manifest read + named refusal :101-107; ManifestSchema.safeParse + formatZodErrors :111-117; the explicit id/version guard :120-125; packaging default and the Loaded line :127-128; entry resolution :131-142; the manifest-deps externalization :155-166; the manifest-deps package.json / pnpm-lock.yaml packing :202-207; computeIntegrity + the compiled manifest :209-213; the artifact write and its printed block :219-230", - "packages/cli/src/utils/osplugin.ts — MANIFEST_FILENAME = 'objectstack.plugin.json' :71; OSPLUGIN_EXT :73; computeIntegrity :61-69 (manifest and SIGNATURE excluded, keys sorted); the ustar writer with mtime pinned to 0 :100-135; readOspluginManifest :163-167", - "packages/cli/src/commands/plugin/publish.ts — artifact discovery :67-77; readOspluginManifest before any network call :82-89; the id/version refusal :90-94; the unsigned-artifact notice :103-105; the auth refusal :107-116", - "packages/spec/src/kernel/manifest.zod.ts — ManifestSchema id :140 and version :202 (both required, version regex-pinned); PluginPackagingSchema :95-97 ('bundled' | 'manifest-deps', ADR-0025 §3.3); PluginRuntimeSchema :85-86 (the trust tier, ADR-0025 §3.6)", - "packages/cli/src/commands/lint.ts :375-400 — the protocol/missing-engines-range rule, its manifest scoping and its fix string", - "packages/rest/src/package-routes.ts :525-536 — the server mirror (400 PACKAGE_MANIFEST_INVALID), recorded in knownGaps rather than scored here", - "content/docs/releases/v15.mdx :514-523 (the release that dated objectstack lint's protocol/missing-engines-range) · ADR-0025 §3.3 / §3.6", + "packages/cli/src/commands/plugin/build.ts — manifest read + named refusal; ManifestSchema.safeParse + formatZodErrors; the explicit id/version guard; packaging default and the Loaded line; entry resolution; the manifest-deps externalization; the manifest-deps package.json / pnpm-lock.yaml packing; computeIntegrity + the compiled manifest; the artifact write and its printed block", + "packages/cli/src/utils/osplugin.ts — MANIFEST_FILENAME = 'objectstack.plugin.json'; OSPLUGIN_EXT; computeIntegrity (manifest and SIGNATURE excluded, keys sorted); the ustar writer with mtime pinned to 0; readOspluginManifest", + "packages/cli/src/commands/plugin/publish.ts — artifact discovery; readOspluginManifest before any network call; the id/version refusal; the unsigned-artifact notice; the auth refusal", + "packages/spec/src/kernel/manifest.zod.ts — ManifestSchema id and version (both required, version regex-pinned); PluginPackagingSchema ('bundled' | 'manifest-deps', ADR-0025 §3.3); PluginRuntimeSchema (the trust tier, ADR-0025 §3.6)", + "packages/cli/src/commands/lint.ts — the protocol/missing-engines-range rule, its manifest scoping and its fix string", + "packages/rest/src/package-routes.ts — the server mirror (400 PACKAGE_MANIFEST_INVALID), recorded in knownGaps rather than scored here", + "content/docs/releases/v15.mdx (the release that dated objectstack lint's protocol/missing-engines-range) · ADR-0025 §3.3 / §3.6", "sibling items: platform-core.manifest-install-contract (the same manifest at the install boundary) · cli.build-own-contract and cli.lint-severity-exit-contract (the os build / os lint exit-code contracts this item leans on but does not re-prove)" ], "history": [ { "revision": 1, "date": "2026-08-24", - "change": "new — the packaging half of classifying the `manifest` capability for the coverage ratchet, which had flagged it UNCLASSIFIED since its liveness ledger landed. Authored VERIFY-FIRST against source, and two plausible sketch claims were corrected before they became clauses: (1) `id`/`version` are REQUIRED by ManifestSchema (:140, :202), so the explicit post-parse guard in build.ts is the empty-string arm rather than the only identity check — the clause now asserts both shapes as different refusals; (2) the manifest os plugin build reads is objectstack.plugin.json, not the objectstack.config.ts the manifest liveness ledger names first. `since` is v15 as a FLOOR, not a discovery: the protocol/missing-engines-range rule this item's last clause covers is dated to v15 by the release notes, and os plugin build already predates the earliest release in the CLI changelog reachable from this checkout. The two open enforce-or-remove findings on this surface (#11330 runtime tier printed-not-enforced, #11331 integrity computed-not-verified) are recorded in `negative` so a run cannot score either as working machinery", + "change": "new — the packaging half of classifying the `manifest` capability for the coverage ratchet, which had flagged it UNCLASSIFIED since its liveness ledger landed. Authored VERIFY-FIRST against source, and two plausible sketch claims were corrected before they became clauses: (1) `id`/`version` are REQUIRED by ManifestSchema, so the explicit post-parse guard in build.ts is the empty-string arm rather than the only identity check — the clause now asserts both shapes as different refusals; (2) the manifest os plugin build reads is objectstack.plugin.json, not the objectstack.config.ts the manifest liveness ledger names first. `since` is v15 as a FLOOR, not a discovery: the protocol/missing-engines-range rule this item's last clause covers is dated to v15 by the release notes, and os plugin build already predates the earliest release in the CLI changelog reachable from this checkout. The two open enforce-or-remove findings on this surface (#11330 runtime tier printed-not-enforced, #11331 integrity computed-not-verified) are recorded in `negative` so a run cannot score either as working machinery", "ref": "#11421" } ] @@ -1534,20 +1534,20 @@ "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" + "for the config-declared arms: the datasource's driver `config` is where `autoMigrate` is authored (sqlite.zod.ts; 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" + "`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). 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", + "off — the driver default when the key is omitted (sql-driver.ts `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", + "dev injection — `os dev`/`os serve --dev` pass 'safe' themselves for the FALLBACK default datasource (storage-driver.ts) and the telemetry datasource (serve.ts); 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)" + "schemaMode gate — safe is honoured only under schemaMode 'managed' (sql-driver.ts 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)" ], "enumSource": { "file": "packages/spec/src/data/driver/common.zod.ts", "export": "SqlAutoMigrateSchema", "expect": 2 }, "steps": [ @@ -1555,7 +1555,7 @@ "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 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), 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" ], @@ -1563,25 +1563,25 @@ { "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)", + "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)", "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'", + "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) — 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", + "verify": "the production boot log carries the sql-driver.ts 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)", + "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)", "evidence": "the safe-boot log + the still-pending plan" }, { @@ -1593,7 +1593,7 @@ { "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`)", + "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 `isDev ? { autoMigrate: 'safe' } : {}`; serve.ts telemetry `isDev ? 'safe' : undefined`)", "evidence": "the paired keyless boot logs" } ], @@ -1606,15 +1606,15 @@ "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)", + "packages/spec/src/data/driver/common.zod.ts (SqlAutoMigrateSchema z.enum(['off','safe']) — 'Dev-only, loosen-only schema self-heal (#2186) … force-disabled under NODE_ENV=production') + sqlite.zod.ts (the authored key; the wasm not-honoured guidance)", + "packages/drivers/driver-sql/src/sql-driver.ts (SqlDriverConfig.autoMigrate docblock: non-destructive alters only), (default 'off'), (reconcileAndWarnDrift — the managed+safe gate, the production ignore warning verbatim, safe-only applyMigrationEntries allowDestructive:false, per-op auto-reconciled info line, post-reconcile re-detect, warn-once loop)", + "packages/drivers/driver-sql/src/schema-drift.ts (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 (the ONE dev injection point for the fallback default datasource: isDev ? { autoMigrate: 'safe' } : {}) + packages/cli/src/commands/serve.ts (telemetry datasource: isDev ? 'safe' : undefined)", + "packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts + sql-driver-unique-tenancy.test.ts + sql-driver-index-drift.test.ts,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" } + { "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/4652-4654 cites the config/field declarations, but the enforcement lives in reconcileAndWarnDrift 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 / serve.ts, '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 851eb6120d..ebb2320511 100644 --- a/docs/qa/platform-checklist/areas/dashboards.json +++ b/docs/qa/platform-checklist/areas/dashboards.json @@ -1071,7 +1071,7 @@ "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" + "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 'no email service' warn arm fires only on boots without the email capability — record which arm the boot log shows" ] }, "steps": [ @@ -1117,7 +1117,7 @@ { "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", + "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 and dispatch warn lines are the oracle when the arm is driven", "evidence": "boot-log excerpt naming the arm" } ], @@ -1137,11 +1137,11 @@ "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.ts (dispatchDue: due filter on next_run_at; owner fail-closed with the verbatim #2849/#2980 refusal; format fork + CJK-safe filename; renderSubject; skipped arm), (nextRunAt — cron wins, schedule timezone, warned fallback), (advanceSchedule 'ok'/markSchedule), (scheduleReport — first next_run_at, eager cron validation, recipients required)", + "packages/plugins/plugin-reports/src/reports-plugin.ts (optional email + the no-email warn), (resolveOwnerContext: undefined — the recorded fail-closed posture pending ADR-0073 M2), (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)", + "packages/plugins/plugin-email/src/email-plugin.ts (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)" ], diff --git a/docs/qa/platform-checklist/areas/i18n.json b/docs/qa/platform-checklist/areas/i18n.json index c9b515d8ca..37543e5e43 100644 --- a/docs/qa/platform-checklist/areas/i18n.json +++ b/docs/qa/platform-checklist/areas/i18n.json @@ -19,7 +19,7 @@ "a second user to assign work to, so the assignment notification has a recipient distinct from the actor" ], "knownGaps": [ - "per-recipient localized notification COPY is not a capability of the app-flow notify path, and this item pins that contract instead of assuming the retired one: `notifications` is a RETIRED top-level translation key whose own spec guidance reads \"`notifications` is the retired object-first dialect — notifications have no translation group, omit them\" (packages/spec/src/system/translation.zod.ts:341, verified on origin/main); `sys_user` declares no `locale` field; and the notify executor interpolates cfg.title against flow variables with no locale lookup anywhere (packages/services/service-automation/src/builtin/notify-node.ts — `const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context))`). Platform-side assignment notifications moved to app-authored flows in #3403, pinned by packages/plugins/plugin-audit/src/audit-writers.test.ts ('does NOT emit an assignment notification when an owner field is set'). ⛔ Do not re-assert a localized-title premise on this item", + "per-recipient localized notification COPY is not a capability of the app-flow notify path, and this item pins that contract instead of assuming the retired one: `notifications` is a RETIRED top-level translation key whose own spec guidance reads \"`notifications` is the retired object-first dialect — notifications have no translation group, omit them\" (packages/spec/src/system/translation.zod.ts, verified on origin/main); `sys_user` declares no `locale` field; and the notify executor interpolates cfg.title against flow variables with no locale lookup anywhere (packages/services/service-automation/src/builtin/notify-node.ts — `const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context))`). Platform-side assignment notifications moved to app-authored flows in #3403, pinned by packages/plugins/plugin-audit/src/audit-writers.test.ts ('does NOT emit an assignment notification when an owner field is set'). ⛔ Do not re-assert a localized-title premise on this item", "the one platform notification path that IS per-recipient localized is a DIFFERENT producer, and knowing it is what stops the over-correction: plugin-audit's collab.mention writer resolves messages.mentionedYou per recipient (resolveWriteLocale(tenantId, uid) → the ADR-0053 localization context) through the long-declared `messages` translation group (packages/plugins/plugin-audit/src/audit-writers.ts + src/translations/messages.ts). That is a plugin-emitted title through an existing group — not a `notifications` group, and not the app-flow path this item drives. 'Notification titles are never localized' is as wrong as 'the bell title should be localized'" ] }, @@ -77,7 +77,7 @@ "#3362", "#3354", "examples/app-showcase/objectstack.config.ts (supportedLocales)", - "packages/spec/src/system/translation.zod.ts:341 (TRANSLATION_KEY_GUIDANCE — `notifications` is the retired object-first dialect; notifications have no translation group)", + "packages/spec/src/system/translation.zod.ts (TRANSLATION_KEY_GUIDANCE — `notifications` is the retired object-first dialect; notifications have no translation group)", "#3403 (platform-side assignment notifications moved to app-authored flows; pinned by packages/plugins/plugin-audit/src/audit-writers.test.ts)", "examples/app-showcase/src/automation/flows/index.ts (the notify_assignee node whose literal cfg.title this item now asserts)", "packages/services/service-automation/src/builtin/notify-node.ts (title = interpolate(cfg.title) — no locale lookup on the flow notify path)", @@ -86,7 +86,7 @@ "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358; the #3362 route-seam regression is the reason the oracle is a live-server network trace, never a unit test", "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": 3, "date": "2026-08-11", "change": "c0/c4 REWRITTEN against the shipped contract (#7684). Both clauses assumed a per-recipient localized notification title — a capability the spec retired — so on a stock app they were unprovable and landed blocked(fixture) rather than testing anything. c0 now asserts the flow-authored literal title with its placeholders interpolated; c4 now asserts the title is recipient-INDEPENDENT, with a per-recipient difference as the finding. The mechanical read/clear clauses (live-server POST /notifications/read + the survives-reload re-read) are untouched and remain the item's substance. The retirement is cited on the item (translation.zod.ts:341, #3403, notify-node.ts) so the next author cannot re-assert the retired premise; the steps drop the unperformable 'set the recipient user's locale' (sys_user has no locale field) and the stale collab.assignment trigger. Verified before editing: the retirement guidance is still on origin/main and no notification-copy translation group has appeared — plugin-audit's per-recipient collab.mention title resolves the pre-existing `messages` group, a different producer, and is recorded in knownGaps so the correction does not overshoot", "ref": "#7684" } + { "revision": 3, "date": "2026-08-11", "change": "c0/c4 REWRITTEN against the shipped contract (#7684). Both clauses assumed a per-recipient localized notification title — a capability the spec retired — so on a stock app they were unprovable and landed blocked(fixture) rather than testing anything. c0 now asserts the flow-authored literal title with its placeholders interpolated; c4 now asserts the title is recipient-INDEPENDENT, with a per-recipient difference as the finding. The mechanical read/clear clauses (live-server POST /notifications/read + the survives-reload re-read) are untouched and remain the item's substance. The retirement is cited on the item (translation.zod.ts, #3403, notify-node.ts) so the next author cannot re-assert the retired premise; the steps drop the unperformable 'set the recipient user's locale' (sys_user has no locale field) and the stale collab.assignment trigger. Verified before editing: the retirement guidance is still on origin/main and no notification-copy translation group has appeared — plugin-audit's per-recipient collab.mention title resolves the pre-existing `messages` group, a different producer, and is recorded in knownGaps so the correction does not overshoot", "ref": "#7684" } ] }, { diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 9cac127d2d..4c3608bd58 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -341,7 +341,7 @@ { "clause": "the delegable-scope read is self-scoped and stays on ITS OWN axis: GET /api/v1/security/my-delegable-scope answers for the caller only (no target-user parameter) and returns ObjectStack delegation scope — positions, permission sets, business-unit subtrees — never a better-auth org invitation role. It may therefore UNDER-report relative to what the caller can invite (an empty scope alongside a delegate who can still mint a member invitation is CORRECT), and it must never grow a role the caller cannot mint", "oracle": "api", - "verify": "the two /security/my-delegable-scope responses match the DelegableScope shape { isTenantAdmin, scopes[{assignablePermissionSets, businessUnitIds}], placeableBusinessUnitIds, assignablePositions } (packages/spec/src/contracts/security-service.ts:92-105) — assert on those keys, NOT on a role list; the plain member's scope is empty or the call is denied. Score the invite-role gate from clauses 1-4 (the endpoint), not from this read (client method security.describeDelegableScope, rest-route-ledger.ts)", + "verify": "the two /security/my-delegable-scope responses match the DelegableScope shape { isTenantAdmin, scopes[{assignablePermissionSets, businessUnitIds}], placeableBusinessUnitIds, assignablePositions } (packages/spec/src/contracts/security-service.ts) — assert on those keys, NOT on a role list; the plain member's scope is empty or the call is denied. Score the invite-role gate from clauses 1-4 (the endpoint), not from this read (client method security.describeDelegableScope, rest-route-ledger.ts)", "evidence": "the two /security/my-delegable-scope responses" } ], @@ -356,13 +356,13 @@ "packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts (ADR-0105 D8 / #3697; the escalation chain the role cap blocks)", "packages/spec/src/identity/organization.zod.ts (InvitationSchema, InvitationStatus enum)", "packages/rest/src/rest-route-ledger.ts (GET /api/v1/security/my-delegable-scope — security.describeDelegableScope, ADR-0090 D12 / ADR-0105 D8, self-scoped read half of the delegated-admin gate)", - "packages/spec/src/contracts/security-service.ts:92-105 (DelegableScope: isTenantAdmin, scopes, placeableBusinessUnitIds, assignablePositions — no invitation-role field, which is why the scope read cannot be the invite picker's allowlist)", + "packages/spec/src/contracts/security-service.ts (DelegableScope: isTenantAdmin, scopes, placeableBusinessUnitIds, assignablePositions — no invitation-role field, which is why the scope read cannot be the invite picker's allowlist)", "packages/spec/src/kernel/public-auth-features.ts (organization feature gates sys_invitation invite/cancel/resend actions)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: invitation scope gates and lifecycle, pinned to the delegated-admin-invite dogfood test", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-08", "change": "added GET /api/v1/security/my-delegable-scope clause (delegated_admin scope excludes admin-mintable roles; plain member empty/denied) — the read half that feeds the invite role picker (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the delegable-scope clause conflated two axes. DelegableScope models ObjectStack positions / permission sets / business units (security-service.ts:92-105) and has NO field that could carry a better-auth org invitation role, so 'the returned role set contains member but not admin' was unassertable — the run watched a delegate mint a member invitation while the endpoint reported an empty assignable set. Rewrote the clause to assert the DelegableScope shape and the self-scoping, moved the invite-role gate onto clauses 1-4 (where the endpoint is the authority), and split the negative: OVER-reporting is the FAIL, UNDER-reporting is the safe direction and must not be filed", "ref": "#7740" } + { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the delegable-scope clause conflated two axes. DelegableScope models ObjectStack positions / permission sets / business units (security-service.ts) and has NO field that could carry a better-auth org invitation role, so 'the returned role set contains member but not admin' was unassertable — the run watched a delegate mint a member invitation while the endpoint reported an empty assignable set. Rewrote the clause to assert the DelegableScope shape and the self-scoping, moved the invite-role gate onto clauses 1-4 (where the endpoint is the authority), and split the negative: OVER-reporting is the FAIL, UNDER-reporting is the safe direction and must not be filed", "ref": "#7740" } ] }, { @@ -393,7 +393,7 @@ "attempt to sign in as the banned user (POST /api/v1/auth/sign-in/email) and capture the refusal", "unban, then verify the same sign-in now succeeds", "set the target's password via the admin set-password (out-of-band recovery); verify the NEW password signs in and the OLD one is refused", - "sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code: better-auth answers get-session with HTTP 200 and a JSON null body when the session is gone (session-of-record.test.ts:165), so a status-only assertion passes against a fully revoked session", + "sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code: better-auth answers get-session with HTTP 200 and a JSON null body when the session is gone (session-of-record.test.ts), so a status-only assertion passes against a fully revoked session", "change the target's role via POST /api/v1/auth/admin/set-role and prove the change bites: an operation the new role gates flips outcome (e.g. promote → an admin-only read now 2xx; demote → it now 403)", "impersonate the target from the admin surface; verify via the API that the impersonation session carries impersonated_by, and screenshot the console's impersonation state; stop impersonating and verify the admin's own session is restored", "POST /api/v1/auth/admin/remove-user for a throwaway user that OWNS at least one showcase row (task/note), then read that owned row back: its owner_id is cleared to null (engine referential-integrity FK clear), the row itself survives, and the owner-anchor transfer guard did NOT veto the cascade (#3023/#3048)", @@ -428,7 +428,7 @@ { "clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a PROTECTED authed request that succeeded a moment earlier is refused immediately after the admin revoke — not at token expiry", "oracle": "api", - "verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause: better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation and a status-only assertion passes against a revoked session (packages/plugins/plugin-auth/src/session-of-record.test.ts:165). If get-session is captured at all, read its BODY (user null) as corroboration only", + "verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause: better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation and a status-only assertion passes against a revoked session (packages/plugins/plugin-auth/src/session-of-record.test.ts). If get-session is captured at all, read its BODY (user null) as corroboration only", "evidence": "the before/after protected-request pair (plus the get-session body, if captured)" }, { @@ -480,12 +480,12 @@ "packages/plugins/plugin-security/src/security-plugin.ts (§A5 #3023 EXEMPTION: __referentialFieldClear owner_id-null cascade rides a server-derived context, the owner-anchor guard must not veto it) + security-plugin.test.ts '[#3023] … engine referential FK clear … is exempt'", "packages/spec/src/kernel/public-auth-features.ts (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0071)", "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts", - "packages/plugins/plugin-auth/src/session-of-record.test.ts:165 (better-auth answers /get-session with HTTP 200 + a JSON null body when the session is gone — NOT 401; a status-only assertion would pass against a fully revoked session)" + "packages/plugins/plugin-auth/src/session-of-record.test.ts (better-auth answers /get-session with HTTP 200 + a JSON null body when the session is gone — NOT 401; a status-only assertion would pass against a fully revoked session)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: admin lifecycle operations with persistence, enforcement, attribution and both-sides gate checks", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-08", "change": "added admin/list-users, create-user (explicit-password-wins §E12 #3031/#3033, signs in), set-role (flips gate outcomes), remove-user, revoke-user-sessions (kills live session mid-flight), each non-admin-refused; plus the §A5 engine cascade exemption clause (delete sys_user → owned rows' owner_id set_null; owner-anchor guard does not veto the system-context cascade, #3023/#3048) (PENDING-GAPS §D + §G)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the revoke-user-sessions clause named get-session's status code as its oracle and expected 401. better-auth's no-session convention is HTTP 200 with a JSON null body (session-of-record.test.ts:165), so the literal 401 expectation misdescribes a CORRECT implementation and a status-only assertion would also pass against a live session's absence. Re-pointed the clause, the step and the negative at the authed-request oracle — a protected request the target could serve a moment earlier, refused on the very next call — with get-session's body kept as corroboration only. The session was provably gone in the run; only the oracle was wrong", "ref": "#7740" }, + { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the revoke-user-sessions clause named get-session's status code as its oracle and expected 401. better-auth's no-session convention is HTTP 200 with a JSON null body (session-of-record.test.ts), so the literal 401 expectation misdescribes a CORRECT implementation and a status-only assertion would also pass against a live session's absence. Re-pointed the clause, the step and the negative at the authed-request oracle — a protected request the target could serve a moment earlier, refused on the very next call — with get-session's body kept as corroboration only. The session was provably gone in the run; only the oracle was wrong", "ref": "#7740" }, { "revision": 4, "date": "2026-08-18", "change": "clauses 1, 2 and the refusal half of clause 9 pinned (QA run #9401 scored this item partial on a pin reaching clause 8 only). The clause-9 pin DERIVES its /admin/ population from the running stack (honoApp.routes UNION auth.api = 31 routes, against the 6 the clause prose names) and fails on any newly mounted route until it is classified, which is what makes it catch a silently unguarded new route. automated.ref now separates what is MANUAL (clause 7, a screenshot oracle) from what is BLOCKED on a product decision (clauses 0, 3, 4, 5, 6 and clause 9's allowed side on the better-auth bucket): those routes refuse the platform admin himself, because ADR-0068 D2 stopped synthesizing the legacy role scalar the vendor's admin plugin authorizes on. knownGaps records the measurement so the next runner reads a 403 there as the surface's state, not as a fixture error", "ref": "#9482" }, { "revision": 5, @@ -779,7 +779,7 @@ { "clause": "update-member-role writes a role from the ADR-0108 closed vocabulary and it bites: the membership read shows the new role (one of owner/admin/delegated_admin/member) and a role-gated operation flips outcome accordingly", "oracle": "api", - "verify": "GET list-members after update-member-role shows the new role; the same gated request returns 2xx vs 403 before/after for that member. The four names are BUILTIN_MEMBERSHIP_ROLE_OPTIONS (packages/spec/src/identity/membership-role.ts:116) — the registered select options for sys_member.role and sys_invitation.role, pinned by packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts", + "verify": "GET list-members after update-member-role shows the new role; the same gated request returns 2xx vs 403 before/after for that member. The four names are BUILTIN_MEMBERSHIP_ROLE_OPTIONS (packages/spec/src/identity/membership-role.ts) — the registered select options for sys_member.role and sys_invitation.role, pinned by packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts", "evidence": "the membership read + the bracketing gated requests" }, { @@ -848,11 +848,11 @@ "requires": [ "the organization capability mounted (for the team half — sys_team / sys_team_member via the better-auth org endpoints)", "the sys_business_unit tree available (managedBy 'platform' — writable over the data API, unlike the better-auth identity tables) with at least a root company node to parent a child under", - "for the scope-geometry clause: the showcase DOES ship a BU-consuming geometry — the `share_new_inquiries_with_field_ops` sharing rule expands the `bu_field_ops` SUBTREE (Field Operations + West/East Coast) onto showcase_inquiry, and the `showcase_field_ops_delegate` adminScope is bounded by the same tree (examples/app-showcase/src/security/sharing-rules.ts:63, seed/index.ts:200-218). The clause RUNS; it is not blocked" + "for the scope-geometry clause: the showcase DOES ship a BU-consuming geometry — the `share_new_inquiries_with_field_ops` sharing rule expands the `bu_field_ops` SUBTREE (Field Operations + West/East Coast) onto showcase_inquiry, and the `showcase_field_ops_delegate` adminScope is bounded by the same tree (examples/app-showcase/src/security/sharing-rules.ts, seed/index.ts). The clause RUNS; it is not blocked" ], "knownGaps": [ "RETIRED (run #7663): the old gap read 'whether a stock persona's read is scoped BY the business-unit tree depends on the seeded config; if none consumes it the tree-widening clause is blocked(fixture)'. A BU-consuming geometry does ship (`share_new_inquiries_with_field_ops` → the bu_field_ops subtree), so the clause runs on stock showcase. Kept as a retired line rather than deleted so a future sweep does not re-block the clause on the retired reasoning", - "ZERO SEEDED PLACEMENTS — the `seed-data-thin` trap for this item, and the thing that actually costs a run: a fresh boot seeds the sys_business_unit TREE (explicit ids, so metadata can reference units statically) but ZERO `sys_business_unit_member` rows — 'users can't be seeded (they sign up), so user↔unit membership and position assignments stay runtime admin actions' (examples/app-showcase/src/data/seed/index.ts:216-218). The sharing rule therefore materializes NOTHING until the tester places someone: create the sys_business_unit_member row FIRST, then read the scoped persona's rows. An empty before/after diff with no placement made is a fixture artifact, not a geometry failure" + "ZERO SEEDED PLACEMENTS — the `seed-data-thin` trap for this item, and the thing that actually costs a run: a fresh boot seeds the sys_business_unit TREE (explicit ids, so metadata can reference units statically) but ZERO `sys_business_unit_member` rows — 'users can't be seeded (they sign up), so user↔unit membership and position assignments stay runtime admin actions' (examples/app-showcase/src/data/seed/index.ts). The sharing rule therefore materializes NOTHING until the tester places someone: create the sys_business_unit_member row FIRST, then read the scoped persona's rows. An empty before/after diff with no placement made is a fixture artifact, not a geometry failure" ] }, "steps": [ @@ -907,8 +907,8 @@ "packages/platform-objects/src/identity/sys-business-unit.object.ts (canonical BU tree ADR-0057 D2; kind enum; parent_business_unit_id self-ref; org_chart tree view; managedBy 'platform' — writable over the data API)", "packages/platform-objects/src/identity/sys-business-unit-member.object.ts (user↔BU placement: function_in_business_unit member/lead/deputy, is_primary)", "docs/qa/platform-checklist/areas/access-security.json (access-security.scope-depth-asymmetry — the depth matrix this cross-references for the tree-widening geometry)", - "examples/app-showcase/src/security/sharing-rules.ts:63 (`share_new_inquiries_with_field_ops` — the shipped BU-consuming geometry: expands the bu_field_ops subtree onto showcase_inquiry)", - "examples/app-showcase/src/data/seed/index.ts:200-218 (the sys_business_unit tree is seeded with explicit ids; user↔unit membership — sys_business_unit_member — and position assignments are NOT seeded, they stay runtime admin actions)" + "examples/app-showcase/src/security/sharing-rules.ts (`share_new_inquiries_with_field_ops` — the shipped BU-consuming geometry: expands the bu_field_ops subtree onto showcase_inquiry)", + "examples/app-showcase/src/data/seed/index.ts (the sys_business_unit tree is seeded with explicit ids; user↔unit membership — sys_business_unit_member — and position assignments are NOT seeded, they stay runtime admin actions)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: team membership rows (create-team/add/remove) + child business-unit creation and user placement on the sys_business_unit tree, with a scope-geometry-consumes-the-tree clause cross-referencing access-security.scope-depth-asymmetry (PENDING-GAPS §C). Org-member management lives in identity-auth.org-membership-team-management", "ref": "claude/platform-test-checklist-ocwugl" }, @@ -1004,8 +1004,8 @@ "fixtures": { "app": "showcase", "requires": [ - "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)" + "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), and the MCP surface defaults ON — unset OS_MCP_SERVER_ENABLED means enabled (packages/types/src/env.ts) — so the oauth2/{authorize,token,consent,create-client,…} family mounts on every stock boot (auth-route-ledger.ts, 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), 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)" ], "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)", @@ -1016,7 +1016,7 @@ "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 — 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", + "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 consentPage → OAuthConsentPage, objectui apps/console/src/App.tsx) — 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)", @@ -1028,7 +1028,7 @@ { "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", + "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; isMcpServerEnabled default true, packages/types/src/env.ts); 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" }, { @@ -1075,10 +1075,10 @@ ], "traps": ["dispatcher-vs-hono-route", "wrong-persona", "hydration-race"], "source": [ - "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/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) + (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 (resolveOidcProviderEnabled: OS_OIDC_PROVIDER_ENABLED ?? config.oidcProvider ?? readMcpServerEnabledEnv — the default-ON chain, #2698) + (resolveDcrEnabled follows the same MCP default) + (oauthProvider plugin registration; consentPage = console /oauth/consent, loginPage = console /login)", + "packages/types/src/env.ts (isMcpServerEnabled — unset means TRUE; explicit false/0/off/no opts out)", + "objectui apps/console/src/App.tsx (/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)" ], @@ -1178,7 +1178,7 @@ ], "knownGaps": [ "observing the `auto` INVITE path needs a transport to capture the invitation; with the dev log transport it is observable, otherwise the invite-vs-fallback split is blocked(fixture). The one-time passwords (auto-fallback + all of temporary) are returned ONLY in the response — the result step must reveal them; they are never persisted", - "the `auto` policy's TEMPORARY-FALLBACK branch is UNREACHABLE on `objectstack dev` (run #7663) — do not score it as a missing behaviour and do not re-derive this next sweep. `auto` falls back only when a row is neither email- nor SMS-deliverable (admin-import-users.ts:216-221), and on dev neither can be made false: service-email / service-sms always register (a log transport is the no-provider fallback, service-sms/src/sms-plugin.ts:149,169), so isEmailServiceAvailable() is a bare 'is a service wired?' check (auth-manager.ts:4054) and returns true; and isPhoneOtpDeliverable() (auth-manager.ts:4074) only returns false for an unconfigured transport when NODE_ENV === 'production', which `objectstack dev` rules out (cli/src/commands/dev.ts:194 spawns with NODE_ENV='development'; serve.ts:490 sets it under --dev). So every email row takes the invite path, and a phone-only row without the phoneNumber plugin fails PHONE_NOT_ENABLED before any plan is chosen. To exercise the fallback, boot WITHOUT an email service (or with NODE_ENV=production + an unconfigured SMS transport) — otherwise score the fallback via the `temporary` policy, which forces the same credential path for every row" + "the `auto` policy's TEMPORARY-FALLBACK branch is UNREACHABLE on `objectstack dev` (run #7663) — do not score it as a missing behaviour and do not re-derive this next sweep. `auto` falls back only when a row is neither email- nor SMS-deliverable (admin-import-users.ts), and on dev neither can be made false: service-email / service-sms always register (a log transport is the no-provider fallback, service-sms/src/sms-plugin.ts,169), so isEmailServiceAvailable() is a bare 'is a service wired?' check (auth-manager.ts) and returns true; and isPhoneOtpDeliverable() (auth-manager.ts) only returns false for an unconfigured transport when NODE_ENV === 'production', which `objectstack dev` rules out (cli/src/commands/dev.ts spawns with NODE_ENV='development'; serve.ts sets it under --dev). So every email row takes the invite path, and a phone-only row without the phoneNumber plugin fails PHONE_NOT_ENABLED before any plan is chosen. To exercise the fallback, boot WITHOUT an email service (or with NODE_ENV=production + an unconfigured SMS transport) — otherwise score the fallback via the `temporary` policy, which forces the same credential path for every row" ] }, "steps": [ @@ -1244,7 +1244,7 @@ "source": [ "objectui packages/app-shell/src/views/identityImport.ts (IdentityPasswordPolicy 'auto'|'none'|'invite'|'temporary'; wraps ImportWizard onto POST /api/v1/auth/admin/import-users; ≤500-row batches; one-time passwords response-only, never persisted; upsert idempotent on email/phone)", "packages/plugins/plugin-auth/src/admin-user-endpoints.ts (POST /api/v1/auth/admin/import-users — platform-admin-gated login-capable account creation; explicit-password/generatePassword resolution)", - "packages/plugins/plugin-auth/src/admin-import-users.ts:216-221 (the `auto` per-row plan: invite where email- or SMS-deliverable, temporary only otherwise) + auth-manager.ts:4054,4074 (isEmailServiceAvailable / isPhoneOtpDeliverable — the two gates that decide it)" + "packages/plugins/plugin-auth/src/admin-import-users.ts (the `auto` per-row plan: invite where email- or SMS-deliverable, temporary only otherwise) + auth-manager.ts,4074 (isEmailServiceAvailable / isPhoneOtpDeliverable — the two gates that decide it)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: admin CSV identity import with password-policy matrix (auto/temporary/invite/none), imported-user sign-in, upsert idempotency, response-only one-time passwords, non-admin denied, grounded in objectui identityImport.ts + admin-user-endpoints.ts (PENDING-GAPS §G)", "ref": "claude/platform-test-checklist-ocwugl" }, @@ -1263,12 +1263,12 @@ "fixtures": { "app": "showcase", "requires": [ - "ARMING, decided BEFORE boot: twoFactor defaults FALSE (`twoFactorFromEnv ?? pluginConfig.twoFactor ?? false`, packages/plugins/plugin-auth/src/auth-manager.ts:2143 and again at the features block ~:4086) and nothing in examples/ turns it on — set OS_AUTH_TWO_FACTOR=true before bootStack/the server starts (the plugin list is resolved ONCE at auth-manager construction; setting it after boot does nothing — packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts:122-128 is the precedent), or flip the Setup mfa_required toggle, which forces plugins.twoFactor on so gated users can comply (packages/plugins/plugin-auth/src/auth-plugin.ts:1302-1315, ADR-0069 D3)", + "ARMING, decided BEFORE boot: twoFactor defaults FALSE (`twoFactorFromEnv ?? pluginConfig.twoFactor ?? false`, packages/plugins/plugin-auth/src/auth-manager.ts and again at the features block) and nothing in examples/ turns it on — set OS_AUTH_TWO_FACTOR=true before bootStack/the server starts (the plugin list is resolved ONCE at auth-manager construction; setting it after boot does nothing — packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts is the precedent), or flip the Setup mfa_required toggle, which forces plugins.twoFactor on so gated users can comply (packages/plugins/plugin-auth/src/auth-plugin.ts, ADR-0069 D3)", "a signed-in user with a known password (the lockout pin enrolls the seeded dev admin, password admin123)", - "TOTP codes: copy the hand-rolled RFC 6238 helper (base32Decode + totp, node:crypto only) from two-factor-lockout.dogfood.test.ts:74-106 — no otplib/speakeasy dependency exists anywhere in this repo and none may be added; better-auth's defaults are the RFC's (SHA-1, 6 digits, 30s)" + "TOTP codes: copy the hand-rolled RFC 6238 helper (base32Decode + totp, node:crypto only) from two-factor-lockout.dogfood.test.ts — no otplib/speakeasy dependency exists anywhere in this repo and none may be added; better-auth's defaults are the RFC's (SHA-1, 6 digits, 30s)" ], "knownGaps": [ - "BROWSER LANE BLOCKED(dependency): the QR-bearing reveal lives on sys_two_factor.enable_two_factor (resultDialog {path:'totpURI',format:'qrcode'} + {path:'backupCodes',format:'code-list'} + acknowledge gate — packages/platform-objects/src/identity/sys-two-factor.object.ts:61-83), but sys_two_factor is mounted in NO app (account.app.ts's tab list stops at sys_oauth_application), so there is no navigable surface that opens that dialog. The navigable variant, sys_user.enable_two_factor (sys-user.object.ts:401-416), declares NO resultDialog — only a static successMessage telling the user to scan a QR that is never rendered, and the source comment at :395-400 says outright the generic action engine can't render it yet. The resultDialog renderer contract itself is a SHOULD addressed to objectui with zero in-repo consumer (packages/spec/src/ui/action.zod.ts:1089-1145). Until an app mounts sys_two_factor or objectui ships the renderer, drive this item over the API and score the browser lane blocked(dependency) — never pass a QR clause on the strength of the successMessage text" + "BROWSER LANE BLOCKED(dependency): the QR-bearing reveal lives on sys_two_factor.enable_two_factor (resultDialog {path:'totpURI',format:'qrcode'} + {path:'backupCodes',format:'code-list'} + acknowledge gate — packages/platform-objects/src/identity/sys-two-factor.object.ts), but sys_two_factor is mounted in NO app (account.app.ts's tab list stops at sys_oauth_application), so there is no navigable surface that opens that dialog. The navigable variant, sys_user.enable_two_factor (sys-user.object.ts), declares NO resultDialog — only a static successMessage telling the user to scan a QR that is never rendered, and the source comment says outright the generic action engine can't render it yet. The resultDialog renderer contract itself is a SHOULD addressed to objectui with zero in-repo consumer (packages/spec/src/ui/action.zod.ts). Until an app mounts sys_two_factor or objectui ships the renderer, drive this item over the API and score the browser lane blocked(dependency) — never pass a QR clause on the strength of the successMessage text" ] }, "steps": [ @@ -1277,8 +1277,8 @@ "sign in as the enrolling user and POST /api/v1/auth/two-factor/enable with { password }; capture the full response body", "parse the returned totpURI: it must be an otpauth://totp/ URI whose label identifies the issuer + account (the app/user), whose secret query param base32-decodes cleanly (use the copied helper), and whose algorithm/digits/period params — where present — are the sane RFC defaults (SHA1/6/30)", "assert backupCodes is a non-empty array of strings and record its size/format", - "cross-check the declared reveal contract against the live shape: sys_two_factor.enable_two_factor's resultDialog.fields[].path values ('totpURI', 'backupCodes' — sys-two-factor.object.ts:78-81) are dot paths into result.data (action.zod.ts resultDialog contract); confirm each resolves against the response the API actually returned", - "probe the re-reveal endpoint: POST /api/v1/auth/two-factor/get-totp-uri (POST — auth-route-ledger.ts:374; the mounted-surface list is publication-not-liveness per #7735, so drive it, don't infer) with { password }, and record the actual behavior: status, whether it demands the password, and whether it returns the SAME secret as enable did — the 'shown only once' promise in the enable dialog copy (sys-two-factor.object.ts:76) does not account for this endpoint existing", + "cross-check the declared reveal contract against the live shape: sys_two_factor.enable_two_factor's resultDialog.fields[].path values ('totpURI', 'backupCodes' — sys-two-factor.object.ts) are dot paths into result.data (action.zod.ts resultDialog contract); confirm each resolves against the response the API actually returned", + "probe the re-reveal endpoint: POST /api/v1/auth/two-factor/get-totp-uri (POST — auth-route-ledger.ts; the mounted-surface list is publication-not-liveness per #7735, so drive it, don't infer) with { password }, and record the actual behavior: status, whether it demands the password, and whether it returns the SAME secret as enable did — the 'shown only once' promise in the enable dialog copy (sys-two-factor.object.ts) does not account for this endpoint existing", "observe-and-flag the navigable surface: read the sys_user action metadata (meta surface) and record that enable_two_factor there carries no resultDialog — the toast-only reveal is a recorded product gap, not a clause to pass or fail this run on", "negative lane: POST /two-factor/enable with a WRONG password (refused, no sys_two_factor row created) and as an anonymous caller (401)" ], @@ -1292,7 +1292,7 @@ { "clause": "enable returns a parseable enrollment: the response carries totpURI as an otpauth://totp/ URI whose label names issuer + account, whose secret base32-decodes (the copied RFC 6238 helper accepts it), and whose algorithm/digits/period are absent-or-sane (better-auth defaults SHA-1/6 digits/30s)", "oracle": "api", - "verify": "parse the URI (the lockout pin's URL trick at two-factor-lockout.dogfood.test.ts:183 — rewrite otpauth:// to https:// and read searchParams); base32Decode(secret) throws on any invalid character, so a clean decode is the assertion", + "verify": "parse the URI (the lockout pin's URL trick at two-factor-lockout.dogfood.test.ts — rewrite otpauth:// to https:// and read searchParams); base32Decode(secret) throws on any invalid character, so a clean decode is the assertion", "evidence": "the enable response + the parsed URI components (secret value redacted to its length)" }, { @@ -1304,7 +1304,7 @@ { "clause": "the declared reveal dialog points at paths the API actually returns: both resultDialog.fields[].path values on sys_two_factor.enable_two_factor ('totpURI', 'backupCodes') resolve as dot paths against the live enable response — a reveal dialog aimed at a path the API doesn't return renders an empty box, and because the reveal is one-shot the user silently loses their only copy of the secret and codes", "oracle": "api", - "verify": "for each declared path, response[path] is defined and of the format-appropriate type (string for qrcode, string[] for code-list). The contract that paths address result.data is packages/spec/src/ui/action.zod.ts (resultDialog block, :1089-1145)", + "verify": "for each declared path, response[path] is defined and of the format-appropriate type (string for qrcode, string[] for code-list). The contract that paths address result.data is packages/spec/src/ui/action.zod.ts (resultDialog block)", "evidence": "the path-by-path resolution table against the captured response" }, { @@ -1314,7 +1314,7 @@ "evidence": "the recorded behavior table (secrets redacted)" }, { - "clause": "OBSERVE-AND-FLAG — the only navigable enrollment surface renders no QR: sys_user.enable_two_factor declares no resultDialog, only a successMessage instructing the user to scan a QR the engine never draws (the source's own admission, sys-user.object.ts:395-400). Recorded as the standing product gap that keeps the browser lane blocked — never scored as a pass", + "clause": "OBSERVE-AND-FLAG — the only navigable enrollment surface renders no QR: sys_user.enable_two_factor declares no resultDialog, only a successMessage instructing the user to scan a QR the engine never draws (the source's own admission, sys-user.object.ts). Recorded as the standing product gap that keeps the browser lane blocked — never scored as a pass", "oracle": "api", "verify": "the meta read of sys_user's actions shows enable_two_factor with successMessage and no resultDialog, while sys_two_factor (mounted nowhere) carries the real reveal dialog", "evidence": "the two action-metadata reads" @@ -1327,13 +1327,13 @@ ], "traps": ["dispatcher-vs-hono-route", "absence-inference"], "source": [ - "packages/plugins/plugin-auth/src/auth-manager.ts:2143 (twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false — resolved once at construction) + ~:4086 (features.twoFactor, same resolution)", - "packages/plugins/plugin-auth/src/auth-route-ledger.ts:214 (POST /two-factor/enable, SDK row) + :374 (POST /two-factor/get-totp-uri in BETTER_AUTH_MOUNTED_SURFACE — publication, not liveness, #7735)", + "packages/plugins/plugin-auth/src/auth-manager.ts (twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false — resolved once at construction) + (features.twoFactor, same resolution)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /two-factor/enable, SDK row) + (POST /two-factor/get-totp-uri in BETTER_AUTH_MOUNTED_SURFACE — publication, not liveness, #7735)", "packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts (endpoint EXISTENCE is already pinned at LEDGERED_PLUGIN_CONFIG twoFactor:true — behavior is this item's gap, not existence)", - "packages/platform-objects/src/identity/sys-two-factor.object.ts:61-83 (enable_two_factor resultDialog: totpURI qrcode + backupCodes code-list + acknowledge; :76 the 'shown only once' copy)", - "packages/platform-objects/src/identity/sys-user.object.ts:395-416 (the navigable variant: no resultDialog, successMessage only; :395-400 the engine-can't-render-it-yet admission)", - "packages/spec/src/ui/action.zod.ts:1089-1145 (resultDialog: dot paths into result.data; renderer contract is a SHOULD to objectui, no in-repo consumer)", - "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts:74-106 (the hand-rolled RFC 6238 helper to copy — no OTP dependency exists or may be added) + :122-128 (arming must precede bootStack) + :178-185 (enable → totpURI → secret extraction precedent)", + "packages/platform-objects/src/identity/sys-two-factor.object.ts (enable_two_factor resultDialog: totpURI qrcode + backupCodes code-list + acknowledge; the 'shown only once' copy)", + "packages/platform-objects/src/identity/sys-user.object.ts (the navigable variant: no resultDialog, successMessage only; the engine-can't-render-it-yet admission)", + "packages/spec/src/ui/action.zod.ts (resultDialog: dot paths into result.data; renderer contract is a SHOULD to objectui, no in-repo consumer)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (the hand-rolled RFC 6238 helper to copy — no OTP dependency exists or may be added) + (arming must precede bootStack) + (enable → totpURI → secret extraction precedent)", "packages/platform-objects/src/apps/account.app.ts (mounts sys_inbox_message/sys_member/sys_account/sys_session/sys_api_key/sys_oauth_application — sys_two_factor absent, which is why the browser lane is blocked)" ], "history": [ @@ -1354,7 +1354,7 @@ "requires": [ "2FA armed BEFORE boot (OS_AUTH_TWO_FACTOR=true precedes bootStack, or the Setup mfa_required toggle forces plugins.twoFactor on — see identity-auth.two-factor-enrollment-reveal's fixtures for the full arming note; every clause here sits behind the same assert-armed guard)", "an in-process harness (bootStack + an ObjectQL system read, exactly the lockout pin's rig) — sys_two_factor's REST surface is get-by-id only (apiMethods ['get']), so reading the enrollment row's verified flag needs the system-context read, not a REST list", - "TOTP codes from the copied RFC 6238 helper (two-factor-lockout.dogfood.test.ts:74-106); no OTP dependency may be added" + "TOTP codes from the copied RFC 6238 helper (two-factor-lockout.dogfood.test.ts); no OTP dependency may be added" ], "knownGaps": [ "browser lane blocked(dependency) — same standing gap as identity-auth.two-factor-enrollment-reveal: no app mounts sys_two_factor and the sys_user surface renders no QR, so the verify-to-activate walk is API-lane only until objectui ships the reveal/verify UI" @@ -1364,10 +1364,10 @@ "arm, boot, run the assert-armed guard (features.twoFactor true, /two-factor/enable non-404) — nothing below is scored without it", "sign in, POST /two-factor/enable { password }, decode the secret from the totpURI (the copied helper)", "IMMEDIATELY read the sys_two_factor row via the system-context ObjectQL read and record the literal value of `verified` — this is the latent-default probe (see the clause), captured before anything else touches the row", - "before any verification, drive the cookie lane: POST /auth/sign-in/email with the password, record whether twoFactorRedirect interposes, then attempt POST /two-factor/verify-totp through the two-factor cookie with a CORRECT code from the new secret — better-auth's documented posture is that the sign-in path refuses an unverified enrolment (TOTP_NOT_ENABLED) before any lockout bookkeeping (the lockout pin's own comment, two-factor-lockout.dogfood.test.ts:187-190); record the actual status/code", - "complete enrollment on the SESSION lane: with the live session (no two-factor cookie), POST /two-factor/verify-totp { code } — this call is isSignIn:false, deliberately touches no lockout counter (test :23-30), and 200s (the setup precedent at :191-194, until now cited by no checklist item)", + "before any verification, drive the cookie lane: POST /auth/sign-in/email with the password, record whether twoFactorRedirect interposes, then attempt POST /two-factor/verify-totp through the two-factor cookie with a CORRECT code from the new secret — better-auth's documented posture is that the sign-in path refuses an unverified enrolment (TOTP_NOT_ENABLED) before any lockout bookkeeping (the lockout pin's own comment, two-factor-lockout.dogfood.test.ts); record the actual status/code", + "complete enrollment on the SESSION lane: with the live session (no two-factor cookie), POST /two-factor/verify-totp { code } — this call is isSignIn:false, deliberately touches no lockout counter (test), and 200s (the setup precedent, until now cited by no checklist item)", "re-read the row: verified must now be true; sys_user.two_factor_enabled true", - "prove activation on the cookie lane: a fresh password sign-in stops at the challenge (twoFactorRedirect: true + two-factor cookie, test :223-230) and a correct code through that cookie completes into a session — NOTE completing enrollment/sign-in rotates the session token (test :375-377), so refresh your bearer before any follow-up calls or a stale-token 401 will read like a defect", + "prove activation on the cookie lane: a fresh password sign-in stops at the challenge (twoFactorRedirect: true + two-factor cookie, test) and a correct code through that cookie completes into a session — NOTE completing enrollment/sign-in rotates the session token (test), so refresh your bearer before any follow-up calls or a stale-token 401 will read like a defect", "negative: a wrong code on the session lane does not activate (row stays unverified; enable can be re-driven)" ], "acceptance": [ @@ -1380,11 +1380,11 @@ { "clause": "a fresh enrollment is INERT at sign-in: between enable and the first successful verify, the new factor cannot complete a sign-in — the cookie-lane verify-totp with a correct code from the just-enabled secret is refused (better-auth's posture: an unverified enrolment draws TOTP_NOT_ENABLED before any lockout bookkeeping). The run records the exact status and error code observed, including whether the password stage even interposed a challenge for the unverified enrollment", "oracle": "api", - "verify": "the pre-verification cookie-lane attempt with a CORRECT code returns non-2xx; record status + body error code and whether sign-in answered twoFactorRedirect at that point. The expectation's source is the lockout pin's setup comment (two-factor-lockout.dogfood.test.ts:187-190) — if the live behavior diverges, record what actually happened rather than forcing the wording", + "verify": "the pre-verification cookie-lane attempt with a CORRECT code returns non-2xx; record status + body error code and whether sign-in answered twoFactorRedirect at that point. The expectation's source is the lockout pin's setup comment (two-factor-lockout.dogfood.test.ts) — if the live behavior diverges, record what actually happened rather than forcing the wording", "evidence": "the sign-in + refused-verify trace" }, { - "clause": "LATENT-DEFAULT PROBE (observe-and-flag, auth-integrity — NOT a confirmed defect): record which value sys_two_factor.verified actually carries immediately post-enable. The suspicion it encodes: the object declares verified with defaultValue: true (sys-two-factor.object.ts:166-170) while better-auth enrolls verified:false, and AUTH_TWO_FACTOR_SCHEMA maps only the four RENAMED fields (backupCodes/userId/failedVerificationCount/lockedUntil — auth-schema-config.ts:366-374; `verified` needs no rename, so its absence from the map is not itself the defect). IF better-auth ever omits the column on insert, the ObjectQL default would mark an unverified enrollment active. The clause is satisfied by the accurate record; verified===false post-enable is the healthy reading", + "clause": "LATENT-DEFAULT PROBE (observe-and-flag, auth-integrity — NOT a confirmed defect): record which value sys_two_factor.verified actually carries immediately post-enable. The suspicion it encodes: the object declares verified with defaultValue: true (sys-two-factor.object.ts) while better-auth enrolls verified:false, and AUTH_TWO_FACTOR_SCHEMA maps only the four RENAMED fields (backupCodes/userId/failedVerificationCount/lockedUntil — auth-schema-config.ts; `verified` needs no rename, so its absence from the map is not itself the defect). IF better-auth ever omits the column on insert, the ObjectQL default would mark an unverified enrollment active. The clause is satisfied by the accurate record; verified===false post-enable is the healthy reading", "oracle": "api", "verify": "the system-context ObjectQL read of the row between enable and verify: capture `verified` verbatim. If it reads TRUE pre-confirmation AND clause 2's sign-in lane accepts the factor, that is an authentication-integrity finding — RUNNER rule 2's carve-out governs its publication (item + clause + detail withheld pending maintainer)", "evidence": "the row read (timestamped between enable and verify)" @@ -1409,11 +1409,11 @@ ], "traps": ["dispatcher-vs-hono-route", "absence-inference"], "source": [ - "packages/plugins/plugin-auth/src/auth-route-ledger.ts:217 (POST /two-factor/verify-totp, SDK row)", - "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts:187-195 (the verify-to-activate SETUP precedent this item promotes to a tested contract: enrolment confirmed through the session path, TOTP_NOT_ENABLED posture for unverified enrolments) + :23-30 (isSignIn decides lockout bookkeeping) + :375-377 (completing enrolment rotates the session token)", - "packages/platform-objects/src/identity/sys-two-factor.object.ts:166-170 (verified: defaultValue TRUE — the declared default the probe interrogates)", - "packages/plugins/plugin-auth/src/auth-schema-config.ts:366-374 (AUTH_TWO_FACTOR_SCHEMA: four renamed fields, verified unmapped-because-unrenamed) + AUTH_TWO_FACTOR_USER_FIELDS (twoFactorEnabled → two_factor_enabled)", - "packages/plugins/plugin-auth/src/auth-manager.ts:2143 (arming default false, resolved once at construction)" + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /two-factor/verify-totp, SDK row)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (the verify-to-activate SETUP precedent this item promotes to a tested contract: enrolment confirmed through the session path, TOTP_NOT_ENABLED posture for unverified enrolments) + (isSignIn decides lockout bookkeeping) + (completing enrolment rotates the session token)", + "packages/platform-objects/src/identity/sys-two-factor.object.ts (verified: defaultValue TRUE — the declared default the probe interrogates)", + "packages/plugins/plugin-auth/src/auth-schema-config.ts (AUTH_TWO_FACTOR_SCHEMA: four renamed fields, verified unmapped-because-unrenamed) + AUTH_TWO_FACTOR_USER_FIELDS (twoFactorEnabled → two_factor_enabled)", + "packages/plugins/plugin-auth/src/auth-manager.ts (arming default false, resolved once at construction)" ], "history": [ { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: the enable→verify→active transition existed only as uncited test SETUP in the lockout pin. Encodes the inert-until-verified contract, the session-lane/cookie-lane split, and the verified-defaultValue:true latent-default probe (observe-and-flag, worded as suspicion not fact)", "ref": "claude/new-session-0pv25p" } @@ -1447,8 +1447,8 @@ "as the signed-in user, POST /two-factor/generate-backup-codes { password }; capture the NEW set from the response", "begin a fresh challenge and attempt an UNSPENT code from the OLD set; capture the refusal — regeneration invalidated the prior set wholesale", "spend one NEW code to prove the new set is live", - "storage-shape observation: read sys_two_factor.backup_codes via the system-context read and record what the column actually carries (the declaration says JSON-serialized — sys-two-factor.object.ts:160-164; record whether the stored form is plaintext codes or an encrypted/hashed blob, without publishing either)", - "observe-and-flag the navigable surface: sys_user.generate_backup_codes (sys-user.object.ts:435-452) declares NO resultDialog — a toast only. On the only surface a user can actually reach, regenerating kills the old set and never shows the new one: a self-inflicted lockout path. The sys_two_factor variant with the one-shot reveal (sys-two-factor.object.ts:104-126) is mounted nowhere", + "storage-shape observation: read sys_two_factor.backup_codes via the system-context read and record what the column actually carries (the declaration says JSON-serialized — sys-two-factor.object.ts; record whether the stored form is plaintext codes or an encrypted/hashed blob, without publishing either)", + "observe-and-flag the navigable surface: sys_user.generate_backup_codes (sys-user.object.ts) declares NO resultDialog — a toast only. On the only surface a user can actually reach, regenerating kills the old set and never shows the new one: a self-inflicted lockout path. The sys_two_factor variant with the one-shot reveal (sys-two-factor.object.ts) is mounted nowhere", "negative: generate-backup-codes with a wrong password refused (old set must survive the refused attempt); anonymous call 401" ], "acceptance": [ @@ -1471,7 +1471,7 @@ "evidence": "the two traces" }, { - "clause": "regeneration invalidates the prior set wholesale: after generate-backup-codes, an UNSPENT code from the old set is refused and a code from the new set works — exactly what the regenerate dialog's own copy promises ('Previous backup codes are now invalid', sys-two-factor.object.ts:113,120)", + "clause": "regeneration invalidates the prior set wholesale: after generate-backup-codes, an UNSPENT code from the old set is refused and a code from the new set works — exactly what the regenerate dialog's own copy promises ('Previous backup codes are now invalid', sys-two-factor.object.ts,120)", "oracle": "api", "verify": "the old-unspent-code refusal + the new-code 200, both at fresh challenges after the regenerate 200", "evidence": "the regenerate response shape + the two spend traces" @@ -1497,10 +1497,10 @@ ], "traps": ["dispatcher-vs-hono-route", "absence-inference"], "source": [ - "packages/plugins/plugin-auth/src/auth-route-ledger.ts:215-216 (POST /two-factor/generate-backup-codes, POST /two-factor/verify-backup-code — SDK rows)", - "packages/platform-objects/src/identity/sys-two-factor.object.ts:104-126 (regenerate_backup_codes: password param, one-shot resultDialog, 'previous codes stop working immediately') + :160-164 (backup_codes storage declaration)", - "packages/platform-objects/src/identity/sys-user.object.ts:435-452 (generate_backup_codes on the navigable surface: successMessage only, NO resultDialog — the recorded lockout path)", - "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (the arming + challenge-cookie rig this item reuses: beginChallenge/cookieHeader; TOTP helper :74-106)" + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /two-factor/generate-backup-codes, POST /two-factor/verify-backup-code — SDK rows)", + "packages/platform-objects/src/identity/sys-two-factor.object.ts (regenerate_backup_codes: password param, one-shot resultDialog, 'previous codes stop working immediately') + (backup_codes storage declaration)", + "packages/platform-objects/src/identity/sys-user.object.ts (generate_backup_codes on the navigable surface: successMessage only, NO resultDialog — the recorded lockout path)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (the arming + challenge-cookie rig this item reuses: beginChallenge/cookieHeader; TOTP helper)" ], "history": [ { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: backup codes had zero coverage — spend/single-use/regenerate-invalidates were asserted nowhere, and the no-reveal regenerate on the navigable sys_user surface (a self-lockout path) was unrecorded", "ref": "claude/new-session-0pv25p" } @@ -1519,7 +1519,7 @@ "app": "showcase", "requires": [ "2FA armed BEFORE boot + an ACTIVE enrollment with retained backup codes (run the enrollment items' sequence first on the same boot)", - "for the re-gate clause: a boot (or Setup patch) with mfa_required on — the toggle itself forces plugins.twoFactor on (auth-plugin.ts:1302-1315), so this variant needs no separate env arming", + "for the re-gate clause: a boot (or Setup patch) with mfa_required on — the toggle itself forces plugins.twoFactor on (auth-plugin.ts), so this variant needs no separate env arming", "in-process system reads (sys_two_factor REST is get-by-id only; post-disable the row's ABSENCE is the assertion, which needs the system-context find)" ], "knownGaps": [ @@ -1534,7 +1534,7 @@ "fresh password sign-in: the response is a full session with NO twoFactorRedirect and no two-factor cookie — the challenge is gone, not just the row", "fire POST /two-factor/verify-backup-code with a retained unspent code anyway (no challenge cookie exists to carry it) and capture the refusal — stale codes are dead, not dormant", "re-enable + verify to confirm the lifecycle is repeatable (a fresh secret, fresh codes — the old secret's TOTP codes must not verify the new enrollment)", - "mfa_required variant (separate boot or Setup patch): with enforced MFA on, disable the active enrollment and record what ACTUALLY happens next, against the source's machinery (auth-manager.ts computeAuthGate ~:5084-5160): the disable itself is NOT refused anywhere in the source; on the next request the gate sees required-but-unenrolled and answers 403 MFA_REQUIRED only once mfa_grace_period_days (default 7) have elapsed from sys_user.mfa_required_at — a stamp written LAZILY the first time the user is seen unenrolled and, as far as the source shows, never cleared on enrollment. So the post-disable posture depends on that stamp: an old elapsed stamp gates immediately; no stamp opens a fresh grace window. Record the stamp value and the observed branch", + "mfa_required variant (separate boot or Setup patch): with enforced MFA on, disable the active enrollment and record what ACTUALLY happens next, against the source's machinery (auth-manager.ts computeAuthGate): the disable itself is NOT refused anywhere in the source; on the next request the gate sees required-but-unenrolled and answers 403 MFA_REQUIRED only once mfa_grace_period_days (default 7) have elapsed from sys_user.mfa_required_at — a stamp written LAZILY the first time the user is seen unenrolled and, as far as the source shows, never cleared on enrollment. So the post-disable posture depends on that stamp: an old elapsed stamp gates immediately; no stamp opens a fresh grace window. Record the stamp value and the observed branch", "while gated (if the gate engaged), confirm the /two-factor/* enrollment endpoints still answer — the re-gate must leave the compliance path open (the mfa_required toggle forces the plugin on for exactly this reason, per its own comment)" ], "acceptance": [ @@ -1571,7 +1571,7 @@ { "clause": "OBSERVE-AND-RECORD — the mfa_required re-gate follows the grace-clock machinery, not a hard block on disable: the source refuses the disable nowhere; re-gating is computeAuthGate answering 403 MFA_REQUIRED for a required-but-unenrolled user once the grace window from sys_user.mfa_required_at elapses, and that stamp is written lazily on first sight of non-enrollment and (per the source) never cleared afterward — so whether the gate bites immediately after a disable or opens a fresh grace window depends on the stamp the user already carries. The run records the stamp, the branch observed, and — the 'not stranded' half — that the /two-factor/* enrollment endpoints remain reachable while gated (mfa_required forces plugins.twoFactor on so gated users can comply). An accurate record satisfies the clause; a gated user whose enrollment endpoints 404 is the FAIL", "oracle": "api", - "verify": "under an mfa_required boot: disable 200 → read sys_user.mfa_required_at → drive an authed request and record 2xx-in-grace vs 403 MFA_REQUIRED → while gated (or with the grace shrunk via mfa_grace_period_days=0), POST /two-factor/enable answers non-404. Source: auth-manager.ts computeAuthGate ~:5084-5160 (lazy stamp :5139-5152, fail-open catch) + auth-plugin.ts:1302-1315 (the forcing)", + "verify": "under an mfa_required boot: disable 200 → read sys_user.mfa_required_at → drive an authed request and record 2xx-in-grace vs 403 MFA_REQUIRED → while gated (or with the grace shrunk via mfa_grace_period_days=0), POST /two-factor/enable answers non-404. Source: auth-manager.ts computeAuthGate (lazy stamp, fail-open catch) + auth-plugin.ts (the forcing)", "evidence": "the recorded branch + stamp + the enrollment-endpoint reachability probe" } ], @@ -1583,10 +1583,10 @@ ], "traps": ["dispatcher-vs-hono-route", "absence-inference"], "source": [ - "packages/plugins/plugin-auth/src/auth-route-ledger.ts:213 (POST /two-factor/disable, SDK row)", - "packages/platform-objects/src/identity/sys-user.object.ts:417-434 (disable_two_factor: password param, visible only while two_factor_enabled) + packages/platform-objects/src/identity/sys-two-factor.object.ts:84-102 (the sys_two_factor variant, same endpoint)", - "packages/plugins/plugin-auth/src/auth-manager.ts ~:5084-5160 (computeAuthGate: MFA_REQUIRED after the grace window; mfa_required_at stamped lazily at :5141-5147, no clearing write anywhere in the source; fail-open) + :4960 (isAuthGateActive)", - "packages/plugins/plugin-auth/src/auth-plugin.ts:1302-1315 (mfa_required forces plugins.twoFactor on — 'otherwise gated users would have no way to comply' — the not-stranded contract the last clause tests)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /two-factor/disable, SDK row)", + "packages/platform-objects/src/identity/sys-user.object.ts (disable_two_factor: password param, visible only while two_factor_enabled) + packages/platform-objects/src/identity/sys-two-factor.object.ts (the sys_two_factor variant, same endpoint)", + "packages/plugins/plugin-auth/src/auth-manager.ts (computeAuthGate: MFA_REQUIRED after the grace window; mfa_required_at stamped lazily, no clearing write anywhere in the source; fail-open) + (isAuthGateActive)", + "packages/plugins/plugin-auth/src/auth-plugin.ts (mfa_required forces plugins.twoFactor on — 'otherwise gated users would have no way to comply' — the not-stranded contract the last clause tests)", "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (arming + challenge rig + TOTP helper precedent, as on the sibling items)" ], "history": [ @@ -1605,38 +1605,38 @@ "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)", + "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, resolved `flags['seed-admin'] ?? true`), 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" + "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) — while the sign-up bypass and the audience gate ask isBootstrapCreation, which counts HUMANS (isHumanUserRow excludes usr_system / role 'system'; audience-posture.ts, auth-manager.ts, [#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)", + "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; ledgered GET /api/v1/auth/bootstrap-status = auth.bootstrapStatus, auth-route-ledger.ts)", "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)", + "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; comment), and the audience gate's isBootstrap arm admits it under the default invite_only posture (audience-posture.ts)", + "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)", "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", + "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); 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)", + "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,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", + "verify": "the sign-up request 2xx on the disableSignUp boot while userCount==0 (auth-manager.ts; audience-posture.ts); get-session then identifies the owner", "evidence": "the sign-up trace + the get-session read" }, { @@ -1648,7 +1648,7 @@ { "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)", + "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)", "evidence": "the navigation trace + the landed screenshot + the meta read" }, { @@ -1674,12 +1674,12 @@ "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)" + "objectui apps/console/src/pages/auth/SetupPage.tsx (renders only at hasOwner:false; creates owner + names the auto-provisioned personal org) + (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 (bootstrap-status exempt from the auth wall) + (the route: hasOwner from dataEngine.count('sys_user'))", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (GET /api/v1/auth/bootstrap-status = auth.bootstrapStatus, objectstack-mount)", + "packages/plugins/plugin-auth/src/auth-manager.ts (the bypass's contract comment) + (the before-hook: isBootstrapCreation flips disableSignUp for this request — [#11767] fixed the inert probe) + (isBootstrapCreation: human rows, fail-closed on a full page)", + "packages/plugins/plugin-auth/src/audience-posture.ts (isHumanUserRow) + (decideAudienceAdmission isBootstrap arm) + (undeclared audience ⇒ invite_only, maintainer ruling 2026-08-24)", + "packages/cli/src/commands/dev.ts,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" } @@ -1697,30 +1697,30 @@ "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)", + "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), 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)", "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", + "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; no env knob) and must declare selfRegistrationPermissionSet (entry validation refuses a permitting posture without it, audience-posture.ts). 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)", + "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)", + "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 — 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)", + "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)", + "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) and the login card withholds the register link (LoginPage registerUrl passed undefined, LoginPage.tsx)", + "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; wired, 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", + "verify": "the /auth/config reads per boot (getPublicConfig: disableSignUp at auth-manager.ts, audiencePosture); stock shows invite_only + disableSignUp false together", "evidence": "the per-boot /auth/config responses" }, { @@ -1749,15 +1749,15 @@ } ], "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", + "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", "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", + "invite_only (stock default; refuse SELF_REGISTRATION_CLOSED, invitation carve-out admits — audience-posture.ts,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,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)" @@ -1765,11 +1765,11 @@ "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)" + "packages/plugins/plugin-auth/src/audience-posture.ts (the single owner of audience admission: resolveAudience default invite_only; decideAudienceAdmission — operator/provider exempt, bootstrap admit, invitation carve-out, posture semantics; SELF_REGISTRATION_CLOSED / EMAIL_DOMAIN_NOT_ALLOWED; pinned domain rules in the module doc)", + "packages/plugins/plugin-auth/src/auth-manager.ts (readDisableSignUpEnv: OS_AUTH_SIGNUP_ENABLED wins, then OS_DISABLE_SIGNUP) + (effective disableSignUp: ssoOnly forces true, else env ?? config) + (the sign-up route raises the audience refusal BEFORE the anti-enumeration shield — measured swallow documented in place) +,5040 (getPublicConfig: disableSignUp NOT forced by posture, features.audiencePosture advertised)", + "packages/spec/src/system/auth-config.zod.ts (AUDIENCE_POSTURES const — invite_only/email_domain/open, the named enum backing posture z.enum) + (AudienceConfigSchema on AuthConfigSchema — config-only, no env knob)", + "objectui apps/console/src/pages/auth/RegisterPage.tsx,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,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" } @@ -1787,7 +1787,7 @@ "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)", + "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), or (b) any audience posture that permits self-registration (open / email_domain), which FORCES requireEmailVerification on (#11739 invariant, auth-manager.ts; the explicit-false contradiction is refused at config entry, audience-posture.ts)", "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" ], @@ -1797,10 +1797,10 @@ ] }, "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", + "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: 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,346,360)", + "resend from the prompt page (useAuth().sendVerificationEmail → POST /api/v1/auth/send-verification-email, ledgered auth-route-ledger.ts) 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; ledger row GET /api/v1/auth/verify-email) — 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", @@ -1816,7 +1816,7 @@ { "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)", + "verify": "the transport output for the sign-up carries the template render with a tokened link (auth-manager.ts)", "evidence": "the captured transport output" }, { @@ -1840,26 +1840,26 @@ { "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", + "verify": "the two /auth/config reads (getPublicConfig mirror, auth-manager.ts) + 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 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); 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-auth/src/auth-manager.ts (requireEmailVerification wiring — [#11739]: a self-registration-permitting posture FORCES it on; otherwise config passthrough) + (emailVerification block: sendOnSignUp/sendOnSignIn/autoSignInAfterVerification/expiresIn passthrough; sendVerificationEmail via template auth.verify_email, failures thrown into the log) + (getPublicConfig mirrors the forced flag)", + "packages/plugins/plugin-auth/src/audience-posture.ts (entry validation refuses the permitting-posture + explicit-false contradiction)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /api/v1/auth/send-verification-email = auth.sendVerificationEmail) + (GET /api/v1/auth/verify-email = auth.verifyEmail)", + "objectui apps/console/src/App.tsx (/verify-email + /verify-email-prompt routes)", + "objectui apps/console/src/pages/auth/VerifyEmailPage.tsx (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 (shown after sign-up or an EMAIL_NOT_VERIFIED-blocked sign-in; resend via useAuth().sendVerificationEmail)", + "objectui apps/console/src/pages/auth/LoginPage.tsx,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": [ @@ -1878,7 +1878,7 @@ "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 WALL-ENFORCING tenancy posture boot: OS_TENANCY_POSTURE=group (or isolated) — the stock default resolves to `single` (resolveTenancyPosture, packages/types/src/env.ts), where multiOrgEnabled is false and NO org chrome may render at all (postureEnforcesWall drives both the flag and the UI predicate — auth-manager.ts)", "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": [ @@ -1886,13 +1886,13 @@ ] }, "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)", + "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)", + "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; 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 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)", + "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); 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) and then performs a FULL-DOCUMENT navigation to the console root (window.location.href = resolveRootUrl(), WorkspaceSwitcher.tsx) — 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)", "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": [ @@ -1911,7 +1911,7 @@ { "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)", + "verify": "the navigation after the dropdown click is a document load of the console root, not a SPA route change (WorkspaceSwitcher.tsx — the comment states the design intent)", "evidence": "the navigation trace" }, { @@ -1942,13 +1942,13 @@ ], "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/layout/WorkspaceSwitcher.tsx (the surface contract: single-org renders nothing, multi-org switches with full-page reload, group posture relabels) +,75 (the two null gates) + (handleSwitch: switchOrganization then window.location.href = resolveRootUrl()) + (group 'Working organization' label + hint) + (manage-members / create-workspace entries, create gated on multiOrgEnabled)", + "objectui packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx (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)" + "objectui packages/app-shell/src/providers/MetadataProvider.tsx (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 (POST /api/v1/auth/organization/set-active = organizations.setActive, requires organization)", + "packages/plugins/plugin-auth/src/auth-manager.ts (multiOrgEnabled = postureEnforcesWall(effectiveTenancyPosture()) — the SAME call the org-create gate makes, #5233/#5261) + area (features advertised)", + "packages/types/src/env.ts (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 cd41d38b65..c6bad7b95f 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -520,7 +520,7 @@ "a predicate multi-write path for the bulk variants (update/delete with multi:true on showcase_task) — NOT reachable over REST, see the knownGap below: author a flow with an `update_record` / `delete_record` node carrying multi:true and fire it through the api trigger" ], "knownGaps": [ - "PREDICATE (multi:true) WRITES ARE UNREACHABLE OVER REST BY DESIGN (run #7690) — do not re-derive this, and do not file the 400 as a defect. #3897 made the batch routes parse their body against the spec contract, and Zod object schemas STRIP unknown keys: `options.multi` and `options.where` can no longer ride into the engine's delete/update options, and `POST /data/:object/deleteMany` now deletes per id (packages/rest/src/rest-server.ts:10461-10492). That is a security boundary, not a gap. The bulk_update/bulk_delete clause therefore needs a FLOW `update_record` / `delete_record` node authored with multi:true, fired through the api trigger — that is the supported predicate-write door" + "PREDICATE (multi:true) WRITES ARE UNREACHABLE OVER REST BY DESIGN (run #7690) — do not re-derive this, and do not file the 400 as a defect. #3897 made the batch routes parse their body against the spec contract, and Zod object schemas STRIP unknown keys: `options.multi` and `options.where` can no longer ride into the engine's delete/update options, and `POST /data/:object/deleteMany` now deletes per id (packages/rest/src/rest-server.ts). That is a security boundary, not a gap. The bulk_update/bulk_delete clause therefore needs a FLOW `update_record` / `delete_record` node authored with multi:true, fired through the api trigger — that is the supported predicate-write door" ] }, "variants": [ @@ -570,7 +570,7 @@ { "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)", + "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); each refusal against webhook-outbox-plugin.ts (401/400/400/404 arms) and http-outbox.ts (assertHttpRedeliverable / assertRedeliverAllowed — the 409 pair and the guard); the no-capability posture and the #10740 tenant threading in the route's own docblock", "evidence": "the redeliver response + replayed receiver hit + the refusal responses, keyed by arm" }, { @@ -582,7 +582,7 @@ { "clause": "seed-not-clobber: the admin-edited row (customized:true) survives redeploys — the deactivation/receiver-URL edit is still there after reboot", "oracle": "api", - "verify": "post-reboot sys_webhook row keeps the admin's values (bootstrap-declared-webhooks.ts:132-145)", + "verify": "post-reboot sys_webhook row keeps the admin's values (bootstrap-declared-webhooks.ts)", "evidence": "before/after row reads" } ], @@ -603,9 +603,9 @@ "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; :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)", + "packages/services/service-messaging/src/http-outbox.ts (delivery statuses, attempts; 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 (redelivery replays the stored signature byte-for-byte)", + "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts (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) + (the #8069 redeliver-guard install; its ABSENCE is an error-level log, not a silent pass)", + "packages/rest/src/rest-server.ts (#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)" ], @@ -637,7 +637,7 @@ { "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", + "change": "coverage sweep: added the redeliver clause — POST /api/v1/webhooks/redeliver (a raw hono mount, webhook-outbox-plugin.ts) 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" } ], @@ -663,9 +663,9 @@ ] }, "variants": [ - "schedule { type: 'cron', expression, timezone (default UTC) } — cron-job-adapter.ts:76-77", - "schedule { type: 'interval', intervalMs } — cron-job-adapter.ts:82", - "schedule { type: 'once', at: ISO datetime } — cron-job-adapter.ts:87", + "schedule { type: 'cron', expression, timezone (default UTC) } — cron-job-adapter.ts", + "schedule { type: 'interval', intervalMs } — cron-job-adapter.ts", + "schedule { type: 'once', at: ISO datetime } — cron-job-adapter.ts", "execution status: success | failed | timeout (JobExecutionStatus; 'running' is the in-flight state)", "rejection: job.id (retired 17.0.0, #4667 — guidance-carrying parse error)", "rejection: unknown key (strictObject #4001; aliases cron/interval→schedule, fn→handler)" @@ -684,7 +684,7 @@ { "clause": "every ScheduleSchema variant actually schedules and fires — cron (with timezone), interval (intervalMs), once (at) — each verified by its OWN sys_job_run row and side effect, not by registration lines alone", "oracle": "api", - "verify": "per-variant run rows + the side effect over the data API (adapters honor all three shapes: cron-job-adapter.ts:71-88; db adapter persists them: db-job-adapter.ts:233-245)", + "verify": "per-variant run rows + the side effect over the data API (adapters honor all three shapes: cron-job-adapter.ts; db adapter persists them: db-job-adapter.ts)", "evidence": "run rows + side-effect reads, keyed by variant" }, { @@ -729,7 +729,7 @@ "source": [ "packages/spec/src/system/job.zod.ts (ScheduleSchema discriminated union; JOB_ID_RETIRED; retryPolicy/timeout docs incl. the 17.0.0 maxRetries default flip #4661; JobExecutionStatus)", "packages/spec/liveness/job.json (per-prop verdicts + the #4509 closed-door rationale)", - "packages/runtime/src/app-plugin.ts:790-855 (registration, enabled/handler skip lines)", + "packages/runtime/src/app-plugin.ts (registration, enabled/handler skip lines)", "packages/services/service-job/src/cron-job-adapter.ts + db-job-adapter.ts (all three schedule shapes; sys_job/sys_job_run persistence) + run-with-policy.ts (retry/timeout enforcement, #3494)", "examples/app-showcase/src/automation/jobs/index.ts (showcase_health_sweep fixture + its #4774/#4888 history)" ], @@ -835,7 +835,7 @@ { "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates and validates: an AUTHED well-formed message — { to, subject } plus AT LEAST ONE of `html` / `text` — lands at the dev transport (200 with result.status 'sent'); an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success", "oracle": "api", - "verify": "the three POST /api/v1/email/send responses: authed 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code (rest-server.ts registerEmailEndpoints: enforceAuth, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts:42-68) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts:232). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", + "verify": "the three POST /api/v1/email/send responses: authed 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code (rest-server.ts registerEmailEndpoints: enforceAuth, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", "evidence": "the three responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)" } ], @@ -859,7 +859,7 @@ "packages/rest/src/rest-server.ts (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", "packages/rest/src/rest-route-ledger.ts (email family — POST /api/v1/email/send → client email.send)", "packages/spec/src/api/error-code-ledger.zod.ts (EMAIL_SEND_FAILED under @objectstack/rest)", - "packages/spec/src/contracts/email-service.ts:42-68 (SendEmailInput — the WIRE shape of POST /api/v1/email/send: to, subject, text?, html?; 'at least one of text or html must be supplied') + packages/plugins/plugin-email/src/email-service.ts:220-240 (normalizeMessage — where the VALIDATION_FAILED texts come from)" + "packages/spec/src/contracts/email-service.ts (SendEmailInput — the WIRE shape of POST /api/v1/email/send: to, subject, text?, html?; 'at least one of text or html must be supplied') + packages/plugins/plugin-email/src/email-service.ts (normalizeMessage — where the VALIDATION_FAILED texts come from)" ], "history": [ { @@ -883,7 +883,7 @@ { "revision": 4, "date": "2026-08-11", - "change": "CORRECTION from run #7690: the raw-send step named a { to, subject, bodyHtml } message body, but those are the TEMPLATE authoring fields — the wire keys are `html`/`text` (SendEmailInput, email-service.ts:42-68), and the literal shape the step named is refused 400 'at least one of text or html is required'. Corrected the step and the clause, added the wrong-vocabulary probe that pins the two vocabularies apart, added a NOT-a-FAIL negative so the refusal is not filed as a defect next run, and cited the contract + normalizeMessage in source", + "change": "CORRECTION from run #7690: the raw-send step named a { to, subject, bodyHtml } message body, but those are the TEMPLATE authoring fields — the wire keys are `html`/`text` (SendEmailInput, email-service.ts), and the literal shape the step named is refused 400 'at least one of text or html is required'. Corrected the step and the clause, added the wrong-vocabulary probe that pins the two vocabularies apart, added a NOT-a-FAIL negative so the refusal is not filed as a defect next run, and cited the contract + normalizeMessage in source", "ref": "#7745" } ] @@ -910,7 +910,7 @@ ], "knownGaps": [ "channels push/slack/teams/webhook have NO delivery implementation (#3197 — notification.zod.ts says the dispatcher dead-letters them, and the enum's 'in-app' spelling vs the implemented 'inbox' channel is a known naming drift); this item tests inbox only and records the dead-letter behavior as a negative, not as deliverable channels", - "THE OLD PERSONA-LOGIN RECIPE IS CLOSED (run #7690): `POST /api/v1/data/sys_account` answers 405, so the trick of inserting a credential row to turn a seeded demo persona into a real login no longer works. It is by design, not drift — sys_account is managedBy:'better-auth' and declares apiMethods ['get','list'] only, so HTTP answers 405 before the identity write guard's 403 (packages/platform-objects/src/identity/sys-account.object.ts:234-241, #1591 / ADR-0092 D2). Mint personas through better-auth instead: sign-up, or POST /api/v1/auth/admin/create-user as the platform admin (explicit password wins — #3031/#3033). Recorded here because every multi-persona item in this area pays for re-deriving it" + "THE OLD PERSONA-LOGIN RECIPE IS CLOSED (run #7690): `POST /api/v1/data/sys_account` answers 405, so the trick of inserting a credential row to turn a seeded demo persona into a real login no longer works. It is by design, not drift — sys_account is managedBy:'better-auth' and declares apiMethods ['get','list'] only, so HTTP answers 405 before the identity write guard's 403 (packages/platform-objects/src/identity/sys-account.object.ts, #1591 / ADR-0092 D2). Mint personas through better-auth instead: sign-up, or POST /api/v1/auth/admin/create-user as the platform admin (explicit password wins — #3031/#3033). Recorded here because every multi-persona item in this area pays for re-deriving it" ] }, "variants": [ @@ -1075,45 +1075,45 @@ "a crypto provider / secret binder (createDatasourceSecretBinder) so the inline secret is bound, not stored cleartext" ], "knownGaps": [ - "STALE claim corrected (rev 2): the federation service is NO LONGER absent from a stock boot — packages/cli/src/commands/serve.ts:2966-2979 wires ExternalDatasourceServicePlugin UNCONDITIONALLY on every os dev / os serve boot (best-effort dynamic import, guarded only against double registration), and :2984-2993 additionally wires createExternalValidationPlugin. On a normal boot the 503-naming clause is therefore not-applicable-live: a working 2xx from remote-tables is the WIRED happy path (covered by integration-system.external-schema-introspection), never a regression against this item. Reaching the 503 needs a boot that genuinely lacks the service — @objectstack/service-datasource uninstalled so serve.ts's dynamic import fails (its catch swallows module-not-found), or a custom host that mounts registerDatasourceAdminRoutes without the federation plugin — or the unit pin (admin-routes.test.ts, the #4225 attribution cases with the service deliberately absent)" + "STALE claim corrected (rev 2): the federation service is NO LONGER absent from a stock boot — packages/cli/src/commands/serve.ts wires ExternalDatasourceServicePlugin UNCONDITIONALLY on every os dev / os serve boot (best-effort dynamic import, guarded only against double registration), and additionally wires createExternalValidationPlugin. On a normal boot the 503-naming clause is therefore not-applicable-live: a working 2xx from remote-tables is the WIRED happy path (covered by integration-system.external-schema-introspection), never a regression against this item. Reaching the 503 needs a boot that genuinely lacks the service — @objectstack/service-datasource uninstalled so serve.ts's dynamic import fails (its catch swallows module-not-found), or a custom host that mounts registerDatasourceAdminRoutes without the federation plugin — or the unit pin (admin-routes.test.ts, the #4225 attribution cases with the service deliberately absent)" ] }, "steps": [ "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/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)", "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", + "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) 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) 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 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'", + "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). 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)", + "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)", "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;`", + "verify": "the six floor probes of step 2 against requireDatasourceAdmin (admin-routes.ts; DATASOURCE_ADMIN_CAPABILITY = 'manage_platform_settings') — 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)", + "verify": "the test response + an unchanged GET /api/v1/datasources afterwards (admin-routes.ts; the saved-name twin); the ledger rows for both spellings (datasource-route-ledger.ts)", "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": "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)", + "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; route: admin-routes.ts)", "evidence": "the migrate responses + the post-migration detail read (or the unit-test output for the bind arm)" }, { @@ -1135,7 +1135,7 @@ "evidence": "the 400 response" }, { - "clause": "WHEN federation is genuinely unwired, the introspection routes (/:name/remote-tables, /:name/test, /:name/object-draft) degrade 503 SERVICE_UNAVAILABLE whose message names the external-datasource service — NOT datasource-admin (the #4225 mis-attribution the resolve() helper exists to prevent, since datasource-admin itself is running fine). NOT REACHABLE on a stock os dev / os serve boot (rev 2): serve.ts:2966-2979 wires ExternalDatasourceServicePlugin unconditionally, so live remote-tables answers as a wired route there — a 2xx on a stock boot is the happy path (integration-system.external-schema-introspection), never a regression against this clause", + "clause": "WHEN federation is genuinely unwired, the introspection routes (/:name/remote-tables, /:name/test, /:name/object-draft) degrade 503 SERVICE_UNAVAILABLE whose message names the external-datasource service — NOT datasource-admin (the #4225 mis-attribution the resolve() helper exists to prevent, since datasource-admin itself is running fine). NOT REACHABLE on a stock os dev / os serve boot (rev 2): serve.ts wires ExternalDatasourceServicePlugin unconditionally, so live remote-tables answers as a wired route there — a 2xx on a stock boot is the happy path (integration-system.external-schema-introspection), never a regression against this clause", "oracle": "test", "verify": "admin-routes.test.ts #4225 cases (external-datasource deliberately absent → 503 'The external-datasource service is not available.' while datasource-admin routes still serve); a live wired-boot 2xx scores this clause not-applicable-live, not fail. Live 503 evidence only from a boot that genuinely lacks the service (see knownGaps)", "evidence": "the unit-test output (or the 503 response on a deliberately unwired boot)" @@ -1143,7 +1143,7 @@ { "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; 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", + "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) 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" } ], @@ -1164,10 +1164,10 @@ "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 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/admin-routes.ts (the eleven routes; the uniform floor docblock + requireDatasourceAdmin + DATASOURCE_ADMIN_CAPABILITY #9391/#9593; splitSecret keeps the secret out of the persisted draft; resolve()/badRequest() per-service attribution #4225/#4249; getDatasource credential-stripped + hasSecret; POST /test draft probe; POST /:name/migrate-credential)", "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/services/service-datasource/src/datasource-credential-migration.ts (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 — a raw IHttpServer mount, which is WHY the family carries its own ledger rather than a rest-route-ledger row)" @@ -1182,7 +1182,7 @@ { "revision": 2, "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", + "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 now wires ExternalDatasourceServicePlugin unconditionally on every os dev / os serve boot (and 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" }, { @@ -1209,11 +1209,11 @@ "app": "showcase", "requires": [ "the shipped read-only SQLite external datasource showcase_external (examples/app-showcase/src/system/datasources/showcase-external.datasource.ts) with its boot-provisioned fixture DB (external-fixture.ts — customers/orders, 5 columns each)", - "os dev wiring: ExternalDatasourceServicePlugin AND registerDatasourceAdminRoutes are both wired unconditionally by packages/cli/src/commands/serve.ts (:2966-2979, :3013+), so both route spellings are live on a stock boot" + "os dev wiring: ExternalDatasourceServicePlugin AND registerDatasourceAdminRoutes are both wired unconditionally by packages/cli/src/commands/serve.ts (+), so both route spellings are live on a stock boot" ], "knownGaps": [ - "live ?schema= NARROWING is not observable on the stock fixture: SQLite introspection returns bare, unqualified table names (sql-driver.ts:9661 introspectSchema reads sqlite_master), so no table carries an attributable schema and the service filter (external-datasource-service.ts:137 — skips only when `tableSchema && tableSchema !== opts.schema`) has nothing to exclude. A live ?schema= therefore returns the SAME full set as no filter — indistinguishable from the pre-#7955 dropped-query bug — so the narrowing clause is scored by the pinned twin-equivalence test (schema-qualified postgres fake), never by the live SQLite read; a schema-qualified (postgres) external fixture would make it live-scoreable", - "the stock fixture's clean text/number/date columns may legitimately produce an EMPTY draft.review[] (notes fire only for unrecognised or lossy type mappings, external-datasource-service.ts:216-228) — an empty array is a pass, not a missing feature" + "live ?schema= NARROWING is not observable on the stock fixture: SQLite introspection returns bare, unqualified table names (sql-driver.ts introspectSchema reads sqlite_master), so no table carries an attributable schema and the service filter (external-datasource-service.ts — skips only when `tableSchema && tableSchema !== opts.schema`) has nothing to exclude. A live ?schema= therefore returns the SAME full set as no filter — indistinguishable from the pre-#7955 dropped-query bug — so the narrowing clause is scored by the pinned twin-equivalence test (schema-qualified postgres fake), never by the live SQLite read; a schema-qualified (postgres) external fixture would make it live-scoreable", + "the stock fixture's clean text/number/date columns may legitimately produce an EMPTY draft.review[] (notes fire only for unrecognised or lossy type mappings, external-datasource-service.ts) — an empty array is a pass, not a missing feature" ] }, "steps": [ @@ -1240,7 +1240,7 @@ "evidence": "the test run output + the live ?schema= responses" }, { - "clause": "object-draft returns a reviewable, compilable draft: 200 with draft.definition (name, label, datasource, external.remoteName, fields incl. primaryKey from the introspected PK) and draft.source — a *.object.ts module whose FIRST line is exactly '// Generated by `os datasource introspect` (ADR-0015). Review before committing.' (external-datasource-service.ts:465) and which typechecks — plus a draft.review[] array whose entries flag unrecognised/lossy type mappings (may be empty on the clean fixture)", + "clause": "object-draft returns a reviewable, compilable draft: 200 with draft.definition (name, label, datasource, external.remoteName, fields incl. primaryKey from the introspected PK) and draft.source — a *.object.ts module whose FIRST line is exactly '// Generated by `os datasource introspect` (ADR-0015). Review before committing.' (external-datasource-service.ts) and which typechecks — plus a draft.review[] array whose entries flag unrecognised/lossy type mappings (may be empty on the clean fixture)", "oracle": "api", "verify": "the draft body fields + the source header string + a scratch typecheck of draft.source; review[] present as an array", "evidence": "the draft response + the typecheck output" @@ -1248,20 +1248,20 @@ { "clause": "the two route spellings stay one operation: GET /datasources/:name/remote-tables ≡ GET /datasources/:name/external/tables and POST /datasources/:name/object-draft {table} ≡ POST /datasources/:name/external/tables/:remote/draft — same service methods (IExternalDatasourceService.listRemoteTables / generateObjectDraft), same sets/drafts, same 400 EXTERNAL_DATASOURCE_ERROR refusal contract (#4249/#4264)", "oracle": "test", - "verify": "remote-tables-twin.equivalence.test.ts (named as the twin pin at external-datasource-routes.ts:126) — plus a live spot-check that both spellings return the same table set on showcase_external", + "verify": "remote-tables-twin.equivalence.test.ts (named as the twin pin at external-datasource-routes.ts) — plus a live spot-check that both spellings return the same table set on showcase_external", "evidence": "the test run output + the paired live reads" }, { "clause": "the auth floor holds and BOTH spellings answer it IDENTICALLY — the #9593 asymmetry this item recorded through rev 1 is CLOSED, so a federation 200 to an unentitled caller is a FAIL, not the documented behaviour: an anonymous caller is refused 401 UNAUTHENTICATED on BOTH spellings (admin: requireDatasourceAdmin/#9391; federation: refuseFederationRequest's shouldDenyAnonymous arm/#9686 — fail-closed, before any service lookup so an anonymous probe cannot learn what is wired), and an authenticated caller WITHOUT manage_platform_settings is refused 403 PERMISSION_DENIED naming the capability on BOTH spellings, keyed on that capability rather than on holding merely some grant. Federation READS (tables, draft) took FEDERATION_READ_CAPABILITY = manage_platform_settings in #9901 — maintainer ruling 2026-08-20, verbatim: 「其他接受你的建议。」— and validate joined them in #10255 (verbatim: 「同意你的意见。」); the family's WRITES (import, refresh-catalog — outside this item's probes) sit on FEDERATION_WRITE_CAPABILITY = manage_metadata above the same 401 floor", "oracle": "api", - "verify": "the four probes of step 8 against admin-routes.ts:361-420 (401 floor then manage_platform_settings 403) and external-datasource-routes.ts:287-324 refuseFederationRequest (the same two decisions out of ONE resolveExecutionContext call — anonymous first, then FEDERATION_READ_CAPABILITY); the agreement is pinned in the twin test's 'listRemoteTables twins agree on WHO may ask (#9686, #9593, #9901)' describe, whose two unentitled cases compare the two spellings' status AND code to each other rather than to a literal", + "verify": "the four probes of step 8 against admin-routes.ts (401 floor then manage_platform_settings 403) and external-datasource-routes.ts refuseFederationRequest (the same two decisions out of ONE resolveExecutionContext call — anonymous first, then FEDERATION_READ_CAPABILITY); the agreement is pinned in the twin test's 'listRemoteTables twins agree on WHO may ask (#9686, #9593, #9901)' describe, whose two unentitled cases compare the two spellings' status AND code to each other rather than to a literal", "evidence": "the 401/403/200 responses" } ], "negative": [ "an introspection-route refusal carrying DATASOURCE_ADMIN_ERROR — or a 503 naming datasource-admin — is the #4249/#4225 mis-attribution regressed: FAIL", "a 200 to an anonymous caller on either spelling is a security FAIL (RUNNER rule 2's authz carve-out governs the report)", - "POST /:name/object-draft without body.table → 400 'Body field \"table\" is required.' (admin-routes.ts:616), never a 500", + "POST /:name/object-draft without body.table → 400 'Body field \"table\" is required.' (admin-routes.ts), never a 500", "a 500 on any ?schema= shape (including the repeated key) is a FAIL against the degrade-to-no-filter contract both spellings pin" ], "traps": [ @@ -1273,24 +1273,24 @@ "ref": "packages/rest/src/remote-tables-twin.equivalence.test.ts (#7955 request-shape + #9686/#9593 admission, driven through the real HonoHttpServer over the real ExternalDatasourceService) + packages/services/service-datasource/src/__tests__/admin-routes.test.ts — the LIVE-mount half (real showcase fixture, real sqlite introspection) is not pinned, drive os dev for it" }, "source": [ - "packages/services/service-datasource/src/admin-routes.ts:517-560 (remote-tables + the #7955 coercion comment), :611-623 (object-draft), :361-420 + :261 (requireDatasourceAdmin — 401 floor then manage_platform_settings 403, #9391/#9593)", - "packages/rest/src/external-datasource-routes.ts:409-448 (the twin family GET /external/tables + POST .../draft), :287-324 (refuseFederationRequest — the #9686 anonymous-deny floor, then the #9901/#10255 capability gate, both decided from one resolveExecutionContext call with anonymous first), :157 (FEDERATION_READ_CAPABILITY = manage_platform_settings, what the two twin routes require) + :172 (FEDERATION_WRITE_CAPABILITY = manage_metadata, the family's write half)", - "packages/services/service-datasource/src/external-datasource-service.ts:137 (listRemoteTables schema filter + allowedSchemas), :183 (generateObjectDraft), :445-481 (renderObjectSource; :465 the generated header)", - "packages/drivers/driver-sql/src/sql-driver.ts:9661 (introspectSchema; SQLite branch reads sqlite_master → bare unqualified table names)", + "packages/services/service-datasource/src/admin-routes.ts (remote-tables + the #7955 coercion comment), (object-draft), + (requireDatasourceAdmin — 401 floor then manage_platform_settings 403, #9391/#9593)", + "packages/rest/src/external-datasource-routes.ts (the twin family GET /external/tables + POST.../draft), (refuseFederationRequest — the #9686 anonymous-deny floor, then the #9901/#10255 capability gate, both decided from one resolveExecutionContext call with anonymous first), (FEDERATION_READ_CAPABILITY = manage_platform_settings, what the two twin routes require) + (FEDERATION_WRITE_CAPABILITY = manage_metadata, the family's write half)", + "packages/services/service-datasource/src/external-datasource-service.ts (listRemoteTables schema filter + allowedSchemas), (generateObjectDraft), (renderObjectSource; the generated header)", + "packages/drivers/driver-sql/src/sql-driver.ts (introspectSchema; SQLite branch reads sqlite_master → bare unqualified table names)", "examples/app-showcase/src/system/datasources/ (showcase-external.datasource.ts + external-fixture.ts)", - "SURFACE NOTE (why api, not mixed): admin-routes.ts:517's comment names a Studio 'sync objects' consumer, but no such consumer was located — the only live callers found are the two CLI commands os datasource list-tables / os datasource introspect (packages/cli/src/commands/datasource/list-tables.ts, introspect.ts — both call the /external/tables spellings), covered by cli.datasource-introspect-codegen (authored in this same sweep). Same #9386/#9417 correction as external-datasource-federated-read rev 2" + "SURFACE NOTE (why api, not mixed): admin-routes.ts's comment names a Studio 'sync objects' consumer, but no such consumer was located — the only live callers found are the two CLI commands os datasource list-tables / os datasource introspect (packages/cli/src/commands/datasource/list-tables.ts, introspect.ts — both call the /external/tables spellings), covered by cli.datasource-introspect-codegen (authored in this same sweep). Same #9386/#9417 correction as external-datasource-federated-read rev 2" ], "history": [ { "revision": 1, "date": "2026-08-20", - "change": "new item from the scoped scan-functionality (扫描功能) coverage sweep: the introspection HAPPY PATH was uncovered — datasource-admin-lifecycle proves only the unwired-503 arm (and since serve.ts:2966 wires federation unconditionally, that arm is unit-pin territory on a stock boot). Covers remote-tables + object-draft on the shipped SQLite fixture, the #7955 ?schema= fix (asserted as fixed, live-limited on SQLite — see knownGaps), twin-spelling equivalence, and the #9391/#9686/#9593 auth floor incl. its pinned divergence", + "change": "new item from the scoped scan-functionality (扫描功能) coverage sweep: the introspection HAPPY PATH was uncovered — datasource-admin-lifecycle proves only the unwired-503 arm (and since serve.ts wires federation unconditionally, that arm is unit-pin territory on a stock boot). Covers remote-tables + object-draft on the shipped SQLite fixture, the #7955 ?schema= fix (asserted as fixed, live-limited on SQLite — see knownGaps), twin-spelling equivalence, and the #9391/#9686/#9593 auth floor incl. its pinned divergence", "ref": "claude/new-session-0pv25p" }, { "revision": 2, "date": "2026-08-24", - "change": "correct the auth-floor texts: the #9593 federation/admin asymmetry this item was authored against (2026-08-20, at 79ebb37) had already been CLOSED by #9901 (reads + writes, 9a1ed7a09) and #10255 (validate, 6ce58a735), so four texts asserted a posture head no longer has — and they INVERT rather than go quiet: a runner would have scored the correct federation 403 as a deviation, with \"a DELIBERATE, pinned divergence (#9593), not a hole to file\" pre-empting the escape hatch. Rewritten to the twin-agreement posture: step 8, this clause, its verify (which read \"capability check deliberately absent\"), and the external-datasource-routes.ts source entry whose :26-28/:215-260 anchors were stale; also persona 1 and step 7, which attributed the capability to the admin spelling alone. Re-verified at 0320a52d, not inherited: all five federation routes open with refuseFederationRequest (reads manage_platform_settings, writes manage_metadata) above the #9686 401 floor, and remote-tables-twin.equivalence.test.ts now compares the two spellings on an unentitled caller instead of pinning a divergence", + "change": "correct the auth-floor texts: the #9593 federation/admin asymmetry this item was authored against (2026-08-20, at 79ebb37) had already been CLOSED by #9901 (reads + writes, 9a1ed7a09) and #10255 (validate, 6ce58a735), so four texts asserted a posture head no longer has — and they INVERT rather than go quiet: a runner would have scored the correct federation 403 as a deviation, with \"a DELIBERATE, pinned divergence (#9593), not a hole to file\" pre-empting the escape hatch. Rewritten to the twin-agreement posture: step 8, this clause, its verify (which read \"capability check deliberately absent\"), and the external-datasource-routes.ts source entry whose anchors were stale; also persona 1 and step 7, which attributed the capability to the admin spelling alone. Re-verified at 0320a52d, not inherited: all five federation routes open with refuseFederationRequest (reads manage_platform_settings, writes manage_metadata) above the #9686 401 floor, and remote-tables-twin.equivalence.test.ts now compares the two spellings on an unentitled caller instead of pinning a divergence", "ref": "#11138" } ] @@ -1309,13 +1309,13 @@ "fixtures": { "app": "showcase", "requires": [ - "serve.ts wires createExternalValidationPlugin unconditionally in the ADR-0015 federation block (packages/cli/src/commands/serve.ts:2984-2993), so the kernel:ready gate runs on every stock boot where @objectstack/runtime + the federation service import cleanly", + "serve.ts wires createExternalValidationPlugin unconditionally in the ADR-0015 federation block (packages/cli/src/commands/serve.ts), so the kernel:ready gate runs on every stock boot where @objectstack/runtime + the federation service import cleanly", "the shipped external datasource + fixture DB (showcase_external — IN SYNC by construction, provisioned idempotently each boot by external-fixture.ts)" ], "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: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", + "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) 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; an unreadable definition also lands there — the def loader swallows the read failure to undefined) — 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), 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" ] }, @@ -1332,7 +1332,7 @@ { "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: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'", + "verify": "the induced-drift boot's abort output against external-errors.ts; the default pinned at external-validation-plugin.ts (resolveOnMismatch `?? 'fail'`; an unreadable datasource definition ALSO lands on it — the memoized def loader answers undefined on a failed read). Only MEASURED diffs reach this throw — 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))" }, { @@ -1342,7 +1342,7 @@ "evidence": "the boot log excerpt + the completed-boot proof" }, { - "clause": "'ignore' is silent: the same drift under onMismatch:'ignore' completes boot with no drift warn at all (external-validation-plugin.ts:215)", + "clause": "'ignore' is silent: the same drift under onMismatch:'ignore' completes boot with no drift warn at all (external-validation-plugin.ts)", "oracle": "log", "verify": "grep the boot log — no '[external-validation]' drift warn; boot completed", "evidence": "the (absence in the) boot log + completed-boot proof" @@ -1350,23 +1350,23 @@ { "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 — 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)", + "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; bootCheckEnabled `!== false`); 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", + "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 (⛔ 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", + "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) — 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", "oracle": "test", - "verify": "list-diagnosed-consumer-sweep.test.ts:334-380 ('the ADR-0015 boot gate must not announce an all-clear over an incomplete sweep') — all four cases; live induction of a loader outage is out of scope (knownGaps)", + "verify": "list-diagnosed-consumer-sweep.test.ts ('the ADR-0015 boot gate must not announce an all-clear over an incomplete sweep') — all four cases; live induction of a loader outage is out of scope (knownGaps)", "evidence": "the test run output" }, { - "clause": "the periodic re-check is observational, never fatal: a datasource declaring external.validation.checkIntervalMs arms an unref'd background timer that emits one external.schema.drift kernel event per drifted object and never throws or aborts the process (external-validation-plugin.ts:239-317)", + "clause": "the periodic re-check is observational, never fatal: a datasource declaring external.validation.checkIntervalMs arms an unref'd background timer that emits one external.schema.drift kernel event per drifted object and never throws or aborts the process (external-validation-plugin.ts)", "oracle": "test", "verify": "external-validation-plugin.test.ts 'background drift detection' describe (one event per drifted object, cross-datasource isolation); live: the 'armed background drift check' log line on a declaring boot", "evidence": "the test run output + the armed-timer log line" @@ -1386,14 +1386,14 @@ ], "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/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" + "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 (#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: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 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)", + "packages/runtime/src/external-validation-plugin.ts (Gate 2 kernel:ready runValidation; the #13149 checkOnBoot gate + skip info line; the #11166 unreachable carve-out — warn and continue, only measured diffs reach onMismatch; the fail throw), (bootCheckEnabled — explicit false only, safe direction), (resolveOnMismatch — `?? 'fail'`), (memoized def loader; a failed read answers undefined → strict defaults), (announceAllClear #6504; the INCOMPLETE-sweep warn), (background drift checks + external.schema.drift events; the boot-step-only ruling ⛔)", + "packages/spec/src/shared/external-errors.ts (renderDiffMessage + ExternalSchemaMismatchError — datasource, object, per-column diffs)", + "packages/cli/src/commands/serve.ts (ADR-0015 federation block — ExternalDatasourceServicePlugin + createExternalValidationPlugin wired unconditionally, best-effort dynamic import)", + "packages/spec/src/data/datasource.zod.ts (validation policy schema; checkOnBoot default true, the checkonboot/validateonboot alias rows 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 (stock policy onMismatch:'warn' — deliberate, per its own comment)", "docs/adr/0015-external-datasource-federation.md §5.2" ], "history": [ @@ -1406,7 +1406,7 @@ { "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", + "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), per-datasource and explicit-false-only (bootCheckEnabled; 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. 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, resolveOnMismatch, announceAllClear, drift checks) and added the external-validation-checkonboot.test.ts pin", "ref": "#13149" } ] @@ -1625,22 +1625,22 @@ "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", + "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) 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)" + "env vars set for each probe's credentialRef before boot (the open tier resolves credentialRef from environment variables — plugin.ts)" ], "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)" + "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,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)", + "bearer → Authorization: Bearer (rest-connector.ts)", + "basic → Authorization: Basic base64(username:password) — username authored in metadata (not a secret), password resolved from credentialRef (rest-connector.ts; connector-auth.zod.ts)", + "api-key, header form → header [headerName ?? 'X-API-Key']: (rest-connector.ts; default applied at resolution, plugin.ts)", + "api-key, query form → paramName rides the query string and the header is NOT set (rest-connector.ts)", + "rejection: auth type 'oauth2' — not an arm of ConnectorInstanceAuthSchema (none/bearer/api-key/basic only, connector-auth.zod.ts); OAuth2 token acquisition is the enterprise tier (ResolvedConnectorAuth doc, connector-auth.zod.ts)", + "rejection: inline `authentication` with any non-'none' type on an authored entry — the #7990 publish refusal (connector.zod.ts)", "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": [ @@ -1655,11 +1655,11 @@ { "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", + "verify": "the echo upstream's captured headers/query per instance against applyAuth (packages/connectors/connector-rest/src/rest-connector.ts); unit fallback per shape in rest-connector.test.ts", "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)", + "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; 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" @@ -1667,13 +1667,13 @@ { "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')", + "verify": "the unset-env boot aborts with the plugin.ts message; pinned by connector-materialization.test.ts ('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)", + "verify": "the two build rejections against connector-auth.zod.ts (union arms) and connector.zod.ts (the superRefine message, quoted fragments verbatim)", "evidence": "the two error texts" }, { @@ -1696,12 +1696,12 @@ "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)" + "packages/spec/src/shared/connector-auth.zod.ts (ConnectorAuthConfigSchema — the runtime shape; ResolvedConnectorAuth = the static open tier, OAuth2 named as the enterprise tier), (ConnectorInstanceAuthSchema — the authored shape: credentialRef instead of inline secrets, four arms, no oauth2)", + "packages/spec/src/integration/connector.zod.ts (the #7990 inline-`authentication` publish refusal, both message variants)", + "packages/services/service-automation/src/plugin.ts (resolveInstanceAuth — credentialRef → ResolvedConnectorAuth; empty resolution throws with the env-var prescription; api-key headerName default applied)", + "packages/connectors/connector-rest/src/rest-connector.ts (applyAuth — the wire shapes per kind) + rest-provider.ts (the factory receives the RESOLVED static subset)", + "packages/connectors/connector-openapi/src/openapi-provider.ts (the openapi provider shares the same static-auth transport)", + "examples/app-showcase/src/system/connectors/index.ts,66,103,142 (all shipped instances auth none; the fixture's own pointer at bearer+credentialRef for real upstreams)" ], "history": [ { @@ -1731,18 +1731,18 @@ "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", + "the federation-unavailable 503 arm is NOT reachable on a stock boot (serve.ts 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, pinned by its own api.test.ts) while the server has answered the envelope `{ success:false, error:{ code:'SERVICE_UNAVAILABLE', message } }` since the sendError consolidation (external-datasource-routes.ts; response-envelope.ts) — 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) is NEWER than the DIFF_LABEL map objectui compiles against: ValidationPanel.tsx 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", + "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 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", + "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 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')", @@ -1752,7 +1752,7 @@ { "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)", + "verify": "the panel on showcase_external vs its absence on a managed datasource (DatasourcePreview.tsx; the unsaved-draft guard ExternalDatasourcePanel.tsx)", "evidence": "the two screenshots (+ the unsaved-draft state if a wizard draft is on hand)" }, { @@ -1762,33 +1762,33 @@ "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", + "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) — 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)", + "verify": "the captured PUT + the post-import GET /meta and /data reads; the twin route at external-datasource-routes.ts; 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)", + "verify": "the captured POST + the rendered timestamp (ExternalDatasourcePanel.tsx,108-123; route at external-datasource-routes.ts)", "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)", + "verify": "the rendered rows against the captured response (ValidationPanel.tsx DIFF_LABEL; route at external-datasource-routes.ts; kinds at external-errors.ts — 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)", + "verify": "the probes of step 8 against refuseFederationRequest (external-datasource-routes.ts — 'read' for validate, 'write' for import/refresh) and the /meta manage_metadata gate (rest-server.ts)", "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", + "verify": "on a deliberately unwired boot: the 503 envelope (external-datasource-routes.ts) vs the UI detector (api.ts, ExternalServiceUnavailableError) and the stale pin (api.test.ts); on a stock boot this clause is not-applicable-live", "evidence": "the 503 response + the actual rendered state, side by side" } ], @@ -1806,12 +1806,12 @@ "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)", + "objectui packages/app-shell/src/views/metadata-admin/external/api.ts (the five-route client; the retired-shape 503 detector; 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 DIFF_LABEL total over the imported spec union — the #4115 recurrence guard) + previews/DatasourcePreview.tsx (panel keyed off schemaMode !== 'managed')", + "objectui packages/app-shell/src/views/metadata-admin/datasource/register.ts (Setup → Datasources = the engine route; left-nav names it directly, objectui#3660)", + "packages/rest/src/external-datasource-routes.ts (the five-route family), (import → 201 { object }, EXTERNAL_IMPORT_ERROR), (refresh-catalog), (validate, #10537 URL-scoped), (refuseFederationRequest — read=manage_platform_settings, write=manage_metadata, #9686 anonymous floor), (the 503 envelope)", + "packages/spec/src/shared/external-errors.ts (SchemaDiffEntryKind — 10 kinds at head incl. index_mismatch/unmapped_index/default_mismatch/unreachable)", + "packages/services/service-datasource/src/external-datasource-service.ts (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": [ diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 50c319ac11..0f349bd703 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1203,12 +1203,12 @@ "fixtures": { "app": "showcase", "requires": [ - "a scratch copy of the app whose objectstack.config.ts plugins array adds `new MigrationRecoveryPlugin()` (exported from @objectstack/runtime — packages/runtime/src/index.ts:58); PlatformObjectsPlugin (the journal object) is auto-registered by serve.ts §5c, but the RECOVERY plugin is not, so a stock config never runs the scan", + "a scratch copy of the app whose objectstack.config.ts plugins array adds `new MigrationRecoveryPlugin()` (exported from @objectstack/runtime — packages/runtime/src/index.ts); PlatformObjectsPlugin (the journal object) is auto-registered by serve.ts §5c, but the RECOVERY plugin is not, so a stock config never runs the scan", "an isolated persistent sqlite DB (own port, -d file:/tmp//journal.db) whose base schema was synced by one prior boot, so sys_migration_journal exists as a table to seed — the same scratch-DB class of fixture cli.migrate-plan-apply-json uses", "a way to hand-insert rows into that sqlite file between boots (sqlite3 CLI or a small node script); the journal's API surface is read-only (apiMethods get/list), so REST cannot seed it" ], "knownGaps": [ - "NO stock boot path composes MigrationRecoveryPlugin: it is exported from @objectstack/runtime and instantiated only by its own unit test — serve.ts auto-registers PlatformObjectsPlugin but never this plugin, standalone-stack/default-host do not, the showcase config's plugins array does not, and buildDataMigrationPlugins (the migrate CLI's boot) does not either. A stock showcase boot therefore runs NO journal scan, and its silence over a seeded interrupted row proves nothing. If the runner judges the scan should be on by default (the plugin header and sys-migration-journal.object.ts:56-58 argue recovery must be 'discoverable with zero host wiring'), that is a composition finding to file — never a reason to tick or to skip the item." + "NO stock boot path composes MigrationRecoveryPlugin: it is exported from @objectstack/runtime and instantiated only by its own unit test — serve.ts auto-registers PlatformObjectsPlugin but never this plugin, standalone-stack/default-host do not, the showcase config's plugins array does not, and buildDataMigrationPlugins (the migrate CLI's boot) does not either. A stock showcase boot therefore runs NO journal scan, and its silence over a seeded interrupted row proves nothing. If the runner judges the scan should be on by default (the plugin header and sys-migration-journal.object.ts argue recovery must be 'discoverable with zero host wiring'), that is a composition finding to file — never a reason to tick or to skip the item." ] }, "steps": [ @@ -1216,37 +1216,37 @@ "hand-seed the crash-shaped state directly into the sqlite file: one row (run_id 'qa-interrupted-1', seq 0, kind 'run_started', plan_hash 'qa', detail '{\"planId\":\"qa_plan\"}') and one row (run_id 'qa-interrupted-1', seq 1, kind 'chunk_started', chunk_index 0) — started ∧ ¬done with an UNKNOWN-outcome chunk; read the table's real columns first (PRAGMA table_info) and respect the unique (run_id, seq) index; record the exact row count", "boot again against the SAME DB; capture the FULL boot log, then GET /api/v1/health and /api/v1/ready once up", "re-count sys_migration_journal rows after the boot and diff against the pre-boot count", - "cross-check the shared vocabulary: run `os migrate resume` (no --run — read-only list) against the same DB and compare its description of qa-interrupted-1 with the boot warning (describeInterruptedRun is one function shared by both, migration-recovery-plugin.ts:113-119)", + "cross-check the shared vocabulary: run `os migrate resume` (no --run — read-only list) against the same DB and compare its description of qa-interrupted-1 with the boot warning (describeInterruptedRun is one function shared by both, migration-recovery-plugin.ts)", "conclude the run (insert run_id 'qa-interrupted-1', seq 2, kind 'run_done') and boot a third time: grep for migration-recovery output (expect none — and this silence is now meaningful because step 3 proved the scan active on this exact composition)", "cover the two degradation arms via the pinned unit suite: pnpm vitest run packages/runtime/src/migration-recovery-plugin.test.ts — the 'quiet degradation' describe (absent journal object → zero warns) and 'reports a scan FAILURE rather than reading it as \"nothing found\"' (throwing find → the scan-failed warning)" ], "acceptance": [ { - "clause": "the boot log warns per interrupted run, naming the run ('qa-interrupted-1'), the plan ('qa_plan'), the UNKNOWN-outcome chunk ('1 chunk(s) with UNKNOWN outcome (0) — started, never confirmed committed'), and the exact acting command — plus the summary 'interrupted migration run(s) found in sys_migration_journal. They are NOT resumed automatically — run \\'os migrate resume\\' to act on them.' Because the scratch boot registers no plan, the per-run line is the UNRESUMABLE variant ('No loaded plugin registers plan \\'qa_plan\\' … Load the package that owns it, then: os migrate resume --run qa-interrupted-1', describeInterruptedRun :147-152) — that variant still names the command; expecting the bare 'Resume with:' line here is a mis-read of the fixture, not a product FAIL", + "clause": "the boot log warns per interrupted run, naming the run ('qa-interrupted-1'), the plan ('qa_plan'), the UNKNOWN-outcome chunk ('1 chunk(s) with UNKNOWN outcome (0) — started, never confirmed committed'), and the exact acting command — plus the summary 'interrupted migration run(s) found in sys_migration_journal. They are NOT resumed automatically — run \\'os migrate resume\\' to act on them.' Because the scratch boot registers no plan, the per-run line is the UNRESUMABLE variant ('No loaded plugin registers plan \\'qa_plan\\' … Load the package that owns it, then: os migrate resume --run qa-interrupted-1', describeInterruptedRun) — that variant still names the command; expecting the bare 'Resume with:' line here is a mis-read of the fixture, not a product FAIL", "oracle": "log", - "verify": "grep the second boot's log for the per-run warning and the NOT-resumed summary (emitted at migration-recovery-plugin.ts:102-108)", + "verify": "grep the second boot's log for the per-run warning and the NOT-resumed summary (emitted at migration-recovery-plugin.ts)", "evidence": "the boot-log excerpt containing both lines" }, { - "clause": "boot is discovery, the CLI is action: the server comes up healthy AFTER warning (health and ready answer 200), and the journal is byte-identical — post-boot row count equals the seeded count, no chunk progressed, no run concluded. Booting never writes to the journal; ANY write (an auto-resume) is the FAIL the plugin's design ruling exists to prevent (migration-recovery-plugin.ts:27-47)", + "clause": "boot is discovery, the CLI is action: the server comes up healthy AFTER warning (health and ready answer 200), and the journal is byte-identical — post-boot row count equals the seeded count, no chunk progressed, no run concluded. Booting never writes to the journal; ANY write (an auto-resume) is the FAIL the plugin's design ruling exists to prevent (migration-recovery-plugin.ts)", "oracle": "api", "verify": "curl /api/v1/health and /api/v1/ready → 200 after the warning appears; sqlite count of sys_migration_journal rows before vs after the boot diffs empty", "evidence": "both probe statuses + the two row counts" }, { - "clause": "on a composition where the scan is PROVEN active (clause 1, same config, same DB), a journal with nothing to find boots in silence: the first boot (empty journal) and the third boot (run concluded by run_done) both emit zero migration-recovery lines — findInterruptedRuns treats run_done as concluded (migration-journal.ts:373) and the empty result returns before any warn (:100). Absence of a warning is only meaningful once the arm is proven — this clause may not be scored on a stock boot, where silence means the plugin was never composed", + "clause": "on a composition where the scan is PROVEN active (clause 1, same config, same DB), a journal with nothing to find boots in silence: the first boot (empty journal) and the third boot (run concluded by run_done) both emit zero migration-recovery lines — findInterruptedRuns treats run_done as concluded (migration-journal.ts) and the empty result returns before any warn. Absence of a warning is only meaningful once the arm is proven — this clause may not be scored on a stock boot, where silence means the plugin was never composed", "oracle": "log", "verify": "grep boots 1 and 3 for 'migration' / 'interrupted' recovery output; both empty, while boot 2 (between them, same composition) warned", "evidence": "the three grep results side by side" }, { - "clause": "degradation arm 1 — a kernel with NO sys_migration_journal object skips the scan in SILENCE by design (the :85 early return): a kernel that never composed platform-objects has no interrupted runs to find, and warning there would train operators to ignore the one output that matters. Staged via the pinned unit test, not a live boot: serve.ts:2085-2098 auto-registers PlatformObjectsPlugin into every served kernel, so no honest served boot lacks the journal", + "clause": "degradation arm 1 — a kernel with NO sys_migration_journal object skips the scan in SILENCE by design (the early return): a kernel that never composed platform-objects has no interrupted runs to find, and warning there would train operators to ignore the one output that matters. Staged via the pinned unit test, not a live boot: serve.ts auto-registers PlatformObjectsPlugin into every served kernel, so no honest served boot lacks the journal", "oracle": "test", "verify": "pnpm vitest run packages/runtime/src/migration-recovery-plugin.test.ts — 'skips silently when sys_migration_journal is not registered' passes (asserts zero warns)", "evidence": "the vitest output naming the passing test" }, { - "clause": "degradation arm 2 — a journal read that THROWS is reported, not swallowed: the warning 'Migration journal scan failed; interrupted migrations (if any) were NOT detected this boot: ' (:93) — because 'I could not check' and 'there is nothing to find' are different answers, and conflating them would let a broken journal masquerade as a clean one", + "clause": "degradation arm 2 — a journal read that THROWS is reported, not swallowed: the warning 'Migration journal scan failed; interrupted migrations (if any) were NOT detected this boot: ' — because 'I could not check' and 'there is nothing to find' are different answers, and conflating them would let a broken journal masquerade as a clean one", "oracle": "test", "verify": "same vitest run — 'reports a scan FAILURE rather than reading it as \"nothing found\"' passes (asserts the warn contains 'scan failed' and 'NOT detected')", "evidence": "the vitest output naming the passing test" @@ -1260,10 +1260,10 @@ "traps": ["absence-inference", "stale-dist"], "automated": { "kind": "unit", "ref": "packages/runtime/src/migration-recovery-plugin.test.ts" }, "source": [ - "packages/runtime/src/migration-recovery-plugin.ts — kernel:ready hook :75; silent no-engine return :78-83; silent absent-journal return :85; scan-failure warning :90-98 (string at :93); per-run warns + NOT-resumed summary :100-108; describeInterruptedRun :120-154 (unresumable variant :147-152); the boot-discovers/CLI-acts ruling in the header :27-47", - "packages/core/src/utils/migration-journal.ts — findInterruptedRuns :363-402 (started ∧ ¬done; run_done concludes :373; a run_failed run fully compensated is settled :380)", + "packages/runtime/src/migration-recovery-plugin.ts — kernel:ready hook; silent no-engine return; silent absent-journal return; scan-failure warning (string); per-run warns + NOT-resumed summary; describeInterruptedRun (unresumable variant); the boot-discovers/CLI-acts ruling in the header", + "packages/core/src/utils/migration-journal.ts — findInterruptedRuns (started ∧ ¬done; run_done concludes; a run_failed run fully compensated is settled)", "packages/platform-objects/src/system/sys-migration-journal.object.ts (row contract, (run_id, seq) unique index, apiMethods get/list — registered by PlatformObjectsPlugin)", - "packages/cli/src/commands/serve.ts:2073-2098 (PlatformObjectsPlugin auto-registered into every served kernel — which is why the absent-journal arm cannot be staged on a served boot; MigrationRecoveryPlugin itself is auto-registered NOWHERE)", + "packages/cli/src/commands/serve.ts (PlatformObjectsPlugin auto-registered into every served kernel — which is why the absent-journal arm cannot be staged on a served boot; MigrationRecoveryPlugin itself is auto-registered NOWHERE)", "packages/runtime/CHANGELOG.md 17.0.0, commit 071d0dc — 'boot reconciliation and os migrate resume for the migration journal' (ADR-0119 D2, #4617 deliverable 3)", "cli.migrate-plan-apply-json (the CLI side of the same journal — this item is the boot-side DETECTION that item's `os migrate resume --json` step presumes)" ], @@ -1298,7 +1298,7 @@ "the DURABLE half of the handshake is NOT scored here: ADR-0087 also refuses an incompatible `sys_packages` row during boot-time rehydration, with boot CONTINUING (v15 release notes). Staging it needs an already-stored incompatible row — DB-level access, or a runtime whose major moved under a previously-installed package. Record blocked(fixture) if attempted. The install-path refusal this item does score is the same assertProtocolCompat call one boundary earlier, so a green here says nothing about the rehydration arm", "OS_METADATA_COLLISION=warn downgrades the namespace refusal to a console warning (registry.ts installPackage) — the policy is read by the registry, not per request, so that leg needs a SECOND server booted with the env set, never a mid-run toggle. Score it only if that boot is cheap, and record which arm the run exercised", "the install's durable leg is best-effort BY DESIGN (protocol.installPackage warns and continues when the `package` service cannot persist to sys_packages), so a probe that installs and does not survive a restart is that documented non-fatal path, not a failure of any clause here", - "NamespaceConflictError is a plain Error subclass with no `code` field (registry.ts:1080) — unlike the protocol refusal, its HTTP status/code mapping is decided by whatever handler catches it, not pinned by the class. The run RECORDS the status and code it observes rather than asserting a pre-agreed one; see `negative` for the direction that is a finding" + "NamespaceConflictError is a plain Error subclass with no `code` field (registry.ts) — unlike the protocol refusal, its HTTP status/code mapping is decided by whatever handler catches it, not pinned by the class. The run RECORDS the status and code it observes rather than asserting a pre-agreed one; see `negative` for the direction that is a finding" ] }, "steps": [ @@ -1341,7 +1341,7 @@ { "clause": "the install-time namespace gate holds OWNERSHIP on all three legs: a second package declaring a namespace another installed package already owns is refused, and the refusal names the namespace, the current owner and the incoming id; the SAME package re-declaring its own namespace is NOT a conflict; and a shareable platform namespace (base / system / sys) is exempt. One leg does not prove the gate — an over-strict gate breaks reinstall, an under-strict one lets a package silently take another's object-name prefix", "oracle": "api", - "verify": "the three probes of step 8; the conflict message must name all three identities (the ADR-0048 refusal text at registry.ts:1086-1095), not merely say 'conflict'", + "verify": "the three probes of step 8; the conflict message must name all three identities (the ADR-0048 refusal text at registry.ts), not merely say 'conflict'", "evidence": "the three responses, with the conflict message quoted in full" }, { @@ -1354,7 +1354,7 @@ "negative": [ "an incompatible package that installs — any 2xx, or a row present after the refusal — is the FAIL this item exists for: it means the mismatch will surface later as a deep schema/renderer crash instead of at the boundary that could name it", "a compatible package REFUSED is the mirror fail and the more expensive one: no-range and unparsed-range must admit, so a false rejection here blocks every package that predates the range convention or is workspace-linked", - "a namespace conflict surfacing as a 5xx is the platform reporting its own fault for a manifest the caller authored — record it as a finding rather than scoring the clause on status alone; NamespaceConflictError carries no `code` (registry.ts:1080), so nothing pins its mapping today", + "a namespace conflict surfacing as a 5xx is the platform reporting its own fault for a manifest the caller authored — record it as a finding rather than scoring the clause on status alone; NamespaceConflictError carries no `code` (registry.ts), so nothing pins its mapping today", "a refusal whose message says only that something conflicted, without the namespace / current owner / incoming id, fails the ownership clause even when the refusal itself is correct — the ADR-0048 text exists so the author knows which of the two packages to rename", "a derived namespace present in the POST echo but absent from the read-back (or vice versa) is a disagreement between the in-memory registry and sys_packages, not a pass" ], @@ -1364,11 +1364,11 @@ "eventual-consistency" ], "source": [ - "packages/metadata-core/src/protocol-handshake.ts — resolveDeclaredRange :80-88 (protocol → platform → legacy engine.objectstack); rangeAdmitsMajor :106 (null = unrecognized, admit-with-warning); checkProtocolCompat :216-262 (the OS_PROTOCOL_INCOMPATIBLE diagnostic and its migrateCommand); assertProtocolCompat :276-301 (the four arms: ok silent, no-range warn, unparsed-range warn, incompatible throw)", - "packages/metadata-protocol/src/protocol.ts :19744-19756 — namespace derivation for namespace-less runtime packages, then assertProtocolCompat(manifest) BEFORE registry.installPackage", - "packages/objectql/src/registry.ts :3466-3490 — the ADR-0048 install-time namespace gate (shareable exemption, same-package exclusion, OS_METADATA_COLLISION=warn downgrade); NamespaceConflictError :1080-1100", + "packages/metadata-core/src/protocol-handshake.ts — resolveDeclaredRange (protocol → platform → legacy engine.objectstack); rangeAdmitsMajor (null = unrecognized, admit-with-warning); checkProtocolCompat (the OS_PROTOCOL_INCOMPATIBLE diagnostic and its migrateCommand); assertProtocolCompat (the four arms: ok silent, no-range warn, unparsed-range warn, incompatible throw)", + "packages/metadata-protocol/src/protocol.ts — namespace derivation for namespace-less runtime packages, then assertProtocolCompat(manifest) BEFORE registry.installPackage", + "packages/objectql/src/registry.ts — the ADR-0048 install-time namespace gate (shareable exemption, same-package exclusion, OS_METADATA_COLLISION=warn downgrade); NamespaceConflictError", "ADR-0087 (metadata protocol upgrade contract) D1 · ADR-0048 (cross-package metadata collision) · ADR-0025 §3.10 #3 (protocol-first precedence)", - "content/docs/releases/v15.mdx :514-523 — 'ADR-0087 completed (#2972)': the handshake on boot-time durable rehydration and on code-defined AppPlugin loads, plus the objectstack lint nudge", + "content/docs/releases/v15.mdx — 'ADR-0087 completed (#2972)': the handshake on boot-time durable rehydration and on code-defined AppPlugin loads, plus the objectstack lint nudge", "sibling items: api-backend.package-rest-lifecycle (the same install door, judged on its 201/409/PATCH contract rather than on the manifest gates) · cli.plugin-manifest-build-contract (the same manifest at the packaging boundary) · platform-core.package-lifecycle-enable-disable (what happens to a package after it installs)" ], "history": [ @@ -1392,8 +1392,8 @@ "fixtures": { "app": "showcase", "requires": [ - "the with-automation composition is any stock boot: `objectstack dev`/`os serve` composes AutomationServicePlugin whenever the app requires the 'automation' capability token (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS, :1056-1063), and the showcase does — so the live-boot legs below need nothing beyond an isolated stock boot", - "the NO-automation composition — the one #12359 measured the 503 on — is `bootStack(showcaseStack)` from @objectstack/verify with the `automation` option omitted (packages/verify/src/harness.ts:322; the option at :286 is only honored at :549-553), which is exactly how the pinned dogfood suite constructs it" + "the with-automation composition is any stock boot: `objectstack dev`/`os serve` composes AutomationServicePlugin whenever the app requires the 'automation' capability token (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS), and the showcase does — so the live-boot legs below need nothing beyond an isolated stock boot", + "the NO-automation composition — the one #12359 measured the 503 on — is `bootStack(showcaseStack)` from @objectstack/verify with the `automation` option omitted (packages/verify/src/harness.ts; the option is only honored), which is exactly how the pinned dogfood suite constructs it" ], "knownGaps": [ "NO stock CLI path boots the showcase WITHOUT the automation service: serve/dev compose it from the app's own `requires`, so a manual runner cannot stage the no-automation composition with `os dev` flags alone. The no-automation legs therefore ride the pinned dogfood suite (automated.ref — its first describe carries an anti-vacuity control asserting the automation service is genuinely absent) or an authored scratch stack config that drops the requirement; the run records WHICH of the two its verdict rests on. Scoring those legs off a stock boot measures the wrong composition" @@ -1405,7 +1405,7 @@ "same boot, the flow half: POST /api/v1/automation/showcase_task_completed/toggle {\"enabled\":false} → 200; re-read the ledger — a metadata_type 'flow' row for showcase_task_completed appears BESIDE the action row, and the flow-name and action-name lists never cross (the discriminator is load-bearing, not decorative)", "restore both switches ({\"enabled\":true} / toggle on) — both rows persist with active true (updated, not deleted); leave the boot as found", "ownership is proven by the boot itself plus the registry read: the stock boot composes BOTH PlatformObjectsPlugin (serve auto-injects it) and the automation service, and it comes up green — a second registrant of sys_metadata_activation would be a boot FAILURE (`registerObject` throws 'already owned by package …', ADR-0029 D3), never a benign duplicate; the dogfood suite additionally reads registry.getObjectOwner('sys_metadata_activation').packageId === 'com.objectstack.platform-objects.activation-ledger'", - "spec receipt: read packages/spec/src/system/constants/platform-object-names.ts — the 'platform-objects' array (:47) carries 'sys_metadata_activation' (:63), 41 names total; the automation service's entry does NOT list it" + "spec receipt: read packages/spec/src/system/constants/platform-object-names.ts — the 'platform-objects' array carries 'sys_metadata_activation', 41 names total; the automation service's entry does NOT list it" ], "acceptance": [ { @@ -1435,7 +1435,7 @@ { "clause": "the spec-side receipt matches the runtime: PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] includes sys_metadata_activation (41 names), so the one-way lint rule and every consumer of the constant attribute the ledger to platform-objects, not to the automation service", "oracle": "build", - "verify": "grep packages/spec/src/system/constants/platform-object-names.ts: 'sys_metadata_activation' appears in the 'platform-objects' array (:47/:63) and in no other package's array; count the array (41)", + "verify": "grep packages/spec/src/system/constants/platform-object-names.ts: 'sys_metadata_activation' appears in the 'platform-objects' array and in no other package's array; count the array (41)", "evidence": "the grep excerpt + the count" } ], @@ -1451,10 +1451,10 @@ "ref": "packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts (both describes); packages/services/service-automation/src/activation-ledger-registration.test.ts (the negative half of the MOVE); packages/platform-objects/src/plugin.test.ts (the owning manifest + routing triple)" }, "source": [ - "packages/platform-objects/src/plugin.ts:51-80 (the registration-home rationale — the measured 503 at :60-62; MOVE-not-add :67-76), :109-152 (why the ledger rides its OWN manifest — the datasource-routing measurement), :153-164 (ACTIVATION_LEDGER_MANIFEST), :181-192 (the two register calls), :193-199 (lean-kernel degradation: no manifest service → the door refuses loudly with 503 rather than keeping a bit that reverts)", - "packages/services/service-automation/src/plugin.ts:738-761 (the flow leg attaches by probe() of the real table — runObjectRegistered no longer vouches for it; not attached on a failed probe, :762-777)", - "packages/spec/src/system/constants/platform-object-names.ts:47,:63 (PLATFORM_OBJECTS_BY_PACKAGE receipt)", - "packages/verify/src/harness.ts:286,:322,:549-553 (bootStack's automation option — how the no-automation composition is constructed)", + "packages/platform-objects/src/plugin.ts (the registration-home rationale — the measured 503; MOVE-not-add), (why the ledger rides its OWN manifest — the datasource-routing measurement), (ACTIVATION_LEDGER_MANIFEST), (the two register calls), (lean-kernel degradation: no manifest service → the door refuses loudly with 503 rather than keeping a bit that reverts)", + "packages/services/service-automation/src/plugin.ts (the flow leg attaches by probe() of the real table — runObjectRegistered no longer vouches for it; not attached on a failed probe)", + "packages/spec/src/system/constants/platform-object-names.ts (PLATFORM_OBJECTS_BY_PACKAGE receipt)", + "packages/verify/src/harness.ts (bootStack's automation option — how the no-automation composition is constructed)", "docs/adr/0126-packaged-metadata-customization-model.md §4 (one generic activation ledger)", "#12438 (the scoped sweep this item lands from), #12419 (the registration-home PR), #12359 (the 503 measurement + the 2026-08-26 「同意」 ruling: registration follows the declaration)" ], @@ -1483,7 +1483,7 @@ "the two shipped switches as row producers: the flow toggle (POST /api/v1/automation/showcase_task_completed/toggle) and the action activation door (POST /api/v1/actions/_activation/showcase_task/showcase_mark_done). Their own door contracts belong to automation.packaged-flow-disable-durable and api-backend.packaged-action-disabled-dispatch — this item owns the ROWS they leave behind" ], "knownGaps": [ - "metadata_type is an untyped Field.text (maxLength 100) with exactly two string-literal writers in the tree — 'flow' (packages/services/service-automation/src/flow-activation-store.ts:51) and 'action' (packages/objectql/src/action-activation.ts:113). There is NO spec enum to pin: the variants below are enumerated BY HAND and deliberately carry no enumSource, so a third writer (ADR-0126 §8 pre-charts tool/skill/position) lands without failing any ratchet. Re-derive the writer list (grep for METADATA_TYPE literals) before scoring the scope wall, and bump this item's variants when a third writer ships", + "metadata_type is an untyped Field.text (maxLength 100) with exactly two string-literal writers in the tree — 'flow' (packages/services/service-automation/src/flow-activation-store.ts) and 'action' (packages/objectql/src/action-activation.ts). There is NO spec enum to pin: the variants below are enumerated BY HAND and deliberately carry no enumSource, so a third writer (ADR-0126 §8 pre-charts tool/skill/position) lands without failing any ratchet. Re-derive the writer list (grep for METADATA_TYPE literals) before scoring the scope wall, and bump this item's variants when a third writer ships", "the reserved organization_id column has NO writer by design (ADR-0126 §5 — the per-org dimension is additive-later), so the org-row-skip leg can only be staged by hand-seeding the sqlite file between boots; the hand-seeded row is the run's own artifact and must be cleaned up (or the DB discarded)" ] }, @@ -1495,31 +1495,31 @@ "re-enable both switches — the SAME rows update to active true (same ids, row count unchanged, nothing vanished)", "uniqueness probe (direct DB): stop the server; in the sqlite file INSERT a second NULL-organization row for an existing (metadata_type, name) pair — the COALESCE'd unique index (ADR-0120 D3 NULL-collapse) refuses with a constraint error; capture it. A hand-written composite naming organization_id verbatim would be NULL-distinct and enforce nothing (#5030, measured) — the refusal is the proof the declared 'organization' arm is live", "org-row-skip probe (direct DB): INSERT one org-CARRYING row (organization_id = any non-null id, metadata_type 'action', name showcase_mark_done, active 0); restart against the SAME DB; POST /api/v1/actions/showcase_task/showcase_mark_done → NOT refused by the switch (the artifact stays ARMED — the install-level read skips org rows, it never merges them); CONTROL, same boot: flip the real install-level switch off through the door → dispatch now 409 ACTION_DISABLED (proves the probe can see the switch, so the armed reading is not vacuous); re-enable and delete the hand-seeded row (or discard the DB)", - "record the operability posture: absence of a row reads as ACTIVE (an empty ledger is the normal stock state, not an error), and the data-API read IS the only 'what is off here' surface today — ObjectQLEngine.listDisabledActions() (packages/objectql/src/engine.ts:3159) has zero route consumers" + "record the operability posture: absence of a row reads as ACTIVE (an empty ledger is the normal stock state, not an error), and the data-API read IS the only 'what is off here' surface today — ObjectQLEngine.listDisabledActions() (packages/objectql/src/engine.ts) has zero route consumers" ], "acceptance": [ { "clause": "reads open, generic writes refused: GET /api/v1/data/sys_metadata_activation answers 200 for an admin (operability surfaces can ask 'what is disabled here'), while create/update/delete through the generic data API each answer 405 naming the refused operation — the rows are written by the ADR-0126 enable/disable doors under a system context and by nothing else (ADR-0103 engine-owned posture)", "oracle": "api", - "verify": "the 200 list read + the three 405s from step 4 (apiMethods ['get','list'] at sys-metadata-activation.object.ts:153-158; the 405-with-allowedOperations shape from checkApiExposure, packages/runtime/src/api-exposure.ts:138-144); the post-probe re-read diffs empty", + "verify": "the 200 list read + the three 405s from step 4 (apiMethods ['get','list'] at sys-metadata-activation.object.ts; the 405-with-allowedOperations shape from checkApiExposure, packages/runtime/src/api-exposure.ts); the post-probe re-read diffs empty", "evidence": "the list read + the three refusal bodies + the empty diff" }, { "clause": "row identity holds and is NULL-collapsed: one row per (metadata_type, name) at install level — organization_id NULL on every row the shipped switches write (§5 reserved, no writer sets it), and a second NULL-org row for the same pair is REFUSED by the declared unique:'organization' index, so one artifact can never carry two contradictory active rows", "oracle": "log", - "verify": "the row dump from step 2 (organization_id NULL throughout) + the sqlite constraint refusal from step 6 (the driver's COALESCE(organization_id,'__global__') NULL-collapse, ADR-0120 D3 — the spelling sys-metadata-activation.object.ts:129-150 documents against the #5030 NULL-distinct hole)", + "verify": "the row dump from step 2 (organization_id NULL throughout) + the sqlite constraint refusal from step 6 (the driver's COALESCE(organization_id,'__global__') NULL-collapse, ADR-0120 D3 — the spelling sys-metadata-activation.object.ts documents against the #5030 NULL-distinct hole)", "evidence": "the row dump + the refused INSERT with its constraint error" }, { "clause": "org-carrying rows are SKIPPED on read, never merged: a hand-seeded row with organization_id set and active=0 leaves the artifact ARMED across a restart — reading it as install-level would apply one organization's choice to the whole installation (the #10243 direction arrived at from the read side); the control leg proves the same probe detects the real install-level switch", "oracle": "api", - "verify": "step 7 both arms: post-restart dispatch NOT 409 with only the org row present (skip at packages/core/src/utils/metadata-activation-store.ts:194), then 409 once the install-level row is written through the door", + "verify": "step 7 both arms: post-restart dispatch NOT 409 with only the org row present (skip at packages/core/src/utils/metadata-activation-store.ts), then 409 once the install-level row is written through the door", "evidence": "the seeded row + the two dispatch statuses bracketing the control flip" }, { "clause": "re-enable UPDATES the row, never deletes it: after flipping both switches back on, the same rows persist with active true — the ledger records the administrator's CHOICE instead of erasing it (ADR-0126 §6 wall 3), and the store's engine slice deliberately has no delete", "oracle": "api", - "verify": "row ids and count identical before/after step 5, active flipped to true/1 (read-then-write setActive, metadata-activation-store.ts:221-250; the delete-less MetadataActivationStoreEngine, :126-133)", + "verify": "row ids and count identical before/after step 5, active flipped to true/1 (read-then-write setActive, metadata-activation-store.ts; the delete-less MetadataActivationStoreEngine)", "evidence": "the before/after row listings keyed by id" }, { @@ -1531,7 +1531,7 @@ { "clause": "durability posture: the object declares NO lifecycle block on purpose — a row is durable configuration, never telemetry, and rows survive a restart byte-identical (a retention policy here would be a data-loss bug that silently re-arms disabled artifacts); absence of a row reads as ACTIVE", "oracle": "api", - "verify": "the pre/post-restart row diff from step 7 is empty apart from the run's own writes (no-lifecycle ruling at sys-metadata-activation.object.ts:49-54); an empty ledger on a fresh boot changes nothing anywhere", + "verify": "the pre/post-restart row diff from step 7 is empty apart from the run's own writes (no-lifecycle ruling at sys-metadata-activation.object.ts); an empty ledger on a fresh boot changes nothing anywhere", "evidence": "the pre/post-restart row diff" } ], @@ -1543,8 +1543,8 @@ "treating this item's variants as a pinned enum is a mis-read: they are hand-enumerated and un-pinned by construction — a stale two-value list over a tree that grew a third writer is a checklist defect, not a platform one; revise the item" ], "variants": [ - "metadata_type 'flow' — written by ObjectStoreFlowActivationStore (packages/services/service-automation/src/flow-activation-store.ts:51)", - "metadata_type 'action' — written by ObjectStoreActionActivationStore (packages/objectql/src/action-activation.ts:113)" + "metadata_type 'flow' — written by ObjectStoreFlowActivationStore (packages/services/service-automation/src/flow-activation-store.ts)", + "metadata_type 'action' — written by ObjectStoreActionActivationStore (packages/objectql/src/action-activation.ts)" ], "traps": ["destructive-in-place", "absence-inference"], "automated": { @@ -1552,11 +1552,11 @@ "ref": "packages/objectql/src/action-activation.test.ts + packages/services/service-automation/src/flow-activation-ledger.test.ts (the store-level pins: org-row skip, 0-reads-as-false, update-not-delete, organization_id never in the write payload); packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts (row shape + update-not-delete on a live boot)" }, "source": [ - "packages/platform-objects/src/system/sys-metadata-activation.object.ts — apiMethods ['get','list'] :153-158; unique:'organization' NULL-collapsed index + the #5030 rationale :129-150; organization_id reserved-NULL :107-119; no-lifecycle ruling :49-54; what the object is NOT (§4 posture) :15-42", - "packages/core/src/utils/metadata-activation-store.ts — org-row skip on read :185-207 (the wall at :194); read-then-write setActive, organization_id never in the payload :221-250; delete-less engine slice :126-133; 0-reads-as-false :199-203", - "packages/runtime/src/api-exposure.ts:113-144 (checkApiExposure — the 405 shape for a whitelisted-methods object)", - "packages/services/service-automation/src/flow-activation-store.ts:51 + packages/objectql/src/action-activation.ts:113 (the two metadata_type string literals — the whole live vocabulary)", - "packages/objectql/src/engine.ts:3159 (listDisabledActions — zero route consumers; the data-API read is the only operability surface)", + "packages/platform-objects/src/system/sys-metadata-activation.object.ts — apiMethods ['get','list']; unique:'organization' NULL-collapsed index + the #5030 rationale; organization_id reserved-NULL; no-lifecycle ruling; what the object is NOT (§4 posture)", + "packages/core/src/utils/metadata-activation-store.ts — org-row skip on read (the wall); read-then-write setActive, organization_id never in the payload; delete-less engine slice; 0-reads-as-false", + "packages/runtime/src/api-exposure.ts (checkApiExposure — the 405 shape for a whitelisted-methods object)", + "packages/services/service-automation/src/flow-activation-store.ts + packages/objectql/src/action-activation.ts (the two metadata_type string literals — the whole live vocabulary)", + "packages/objectql/src/engine.ts (listDisabledActions — zero route consumers; the data-API read is the only operability surface)", "docs/adr/0126-packaged-metadata-customization-model.md §3 (scope wall + amendment ruling 1), §4 (row contract), §5 (org column reserved), §6 wall 3 (record the choice, never erase it)", "#12438 (the scoped sweep), #12419 (registration home — the sibling item), #12159 (the permission-rides-its-own-field ruling, 2026-08-26)" ], @@ -1584,16 +1584,16 @@ "the shipped extension fixture: examples/app-showcase/src/data/extensions/account.extension.ts (AccountExtension — loyalty_tier select bronze/silver/gold/platinum, linkedin_url url, csat_score number 0-100 onto showcase_account, priority 210), registered through the showcase manifest's objectExtensions — present on any stock boot" ], "knownGaps": [ - "the COLLISION-REFUSAL half of the E1 claim DOES NOT EXIST in source, so no clause asserts it: a colliding extension field name is NOT refused at build or boot — mergeObjectDefinitions spreads extension.fields over base.fields (packages/objectql/src/registry.ts:146) and the authoring schema documents exactly that semantics ('Fields to add/override', priority 'higher = applied later (wins on conflict)' — packages/spec/src/data/object.zod.ts:2979,:2996-2997). The only collision gate in the tree is managed-extension-fields.test.ts (plugin-auth), which covers better-auth-managed sys objects only, and ADR-0126 §3 adopts it as Regime E PRIOR ART, not as a live generic gate. A colliding extension field therefore silently OVERRIDES the shipped field today: record the posture, do not stage a collision probe expecting a refusal, and do not file the override as a run FAIL — the missing generic gate is a standing product gap tracked outside this item" + "the COLLISION-REFUSAL half of the E1 claim DOES NOT EXIST in source, so no clause asserts it: a colliding extension field name is NOT refused at build or boot — mergeObjectDefinitions spreads extension.fields over base.fields (packages/objectql/src/registry.ts) and the authoring schema documents exactly that semantics ('Fields to add/override', priority 'higher = applied later (wins on conflict)' — packages/spec/src/data/object.zod.ts). The only collision gate in the tree is managed-extension-fields.test.ts (plugin-auth), which covers better-auth-managed sys objects only, and ADR-0126 §3 adopts it as Regime E PRIOR ART, not as a live generic gate. A colliding extension field therefore silently OVERRIDES the shipped field today: record the posture, do not stage a collision probe expecting a refusal, and do not file the override as a run FAIL — the missing generic gate is a standing product gap tracked outside this item" ] }, "steps": [ "additive half — CROSS-REF, do not duplicate: platform-core.metadata-registry-serving owns the overlay steps (its steps 7-8 / clauses 5-6): the three AccountExtension fields present in the MERGED GET /api/v1/meta/object/showcase_account, rendering on the form, and a loyalty_tier + csat_score write round-tripping through /api/v1/data/showcase_account. Cite that item's verdicts when the same run scored them; otherwise drive exactly those steps here", "capture the BEFORE image for the refusal half: GET /api/v1/meta/object/showcase_account and keep the full served field inventory", - "in-place refusal, door 1 (org-overlay): PUT /api/v1/meta/object/showcase_account with the served body plus ONE added field qa_inplace_probe_ and NO ?package= → expect 403 NOT_OVERRIDABLE (type 'object' is not in the ADR-0005 overlay opt-ins; the refusal names allowRuntimeCreate/allowOrgOverride and the OS_METADATA_WRITABLE escape hatch — packages/metadata-protocol/src/protocol.ts:11370-11386)", + "in-place refusal, door 1 (org-overlay): PUT /api/v1/meta/object/showcase_account with the served body plus ONE added field qa_inplace_probe_ and NO ?package= → expect 403 NOT_OVERRIDABLE (type 'object' is not in the ADR-0005 overlay opt-ins; the refusal names allowRuntimeCreate/allowOrgOverride and the OS_METADATA_WRITABLE escape hatch — packages/metadata-protocol/src/protocol.ts)", "in-place refusal, door 2 (package write): repeat the same PUT with ?package=com.example.showcase (the showcase's read-only code package) → expect the read-only-base refusal (403 ITEM_LOCKED class — the same door access-security.owd-save-gate's control leg measured on stock, #9477); record WHICH door answered and the exact status + code, reading declaredCode where the wire code is a mapped generic", "after both refusals: re-GET /api/v1/meta/object/showcase_account and diff against step 2 — byte-identical; qa_inplace_probe_ absent, the three extension fields still present", - "record the Regime E provenance posture: the extension arrived AS A PACKAGE — it registers as an 'extend' contributor (packages/objectql/src/engine.ts:4618-4636) folded over the base at resolution (mergeObjectDefinitions), the base package's authored definition is never edited, and package identity supplies provenance and upgrade isolation (ADR-0126 §3: 'the customization IS a package', install-grain not per-org)" + "record the Regime E provenance posture: the extension arrived AS A PACKAGE — it registers as an 'extend' contributor (packages/objectql/src/engine.ts) folded over the base at resolution (mergeObjectDefinitions), the base package's authored definition is never edited, and package identity supplies provenance and upgrade isolation (ADR-0126 §3: 'the customization IS a package', install-grain not per-org)" ], "acceptance": [ { @@ -1624,12 +1624,12 @@ "traps": ["dispatcher-vs-hono-route", "cache-staleness"], "source": [ "examples/app-showcase/src/data/extensions/account.extension.ts (AccountExtension — the shipped fixture, priority 210)", - "packages/objectql/src/registry.ts:137-171 (mergeObjectDefinitions — additive fields fold, the spread at :146) + :4566-4648 vicinity via engine: packages/objectql/src/engine.ts:4618-4636 (the objectExtensions loop registering 'extend' contributors)", - "packages/spec/src/data/object.zod.ts:2938-3011 (ObjectExtensionSchema — what an extension may carry, the #4001 silently-dropped history, 'Fields to add/override', priority wins-on-conflict; defineObjectExtension :3009)", - "packages/metadata-protocol/src/protocol.ts:11370-11386 (the NOT_OVERRIDABLE refusal text and escape hatch)", + "packages/objectql/src/registry.ts (mergeObjectDefinitions — additive fields fold, the spread) + vicinity via engine: packages/objectql/src/engine.ts (the objectExtensions loop registering 'extend' contributors)", + "packages/spec/src/data/object.zod.ts (ObjectExtensionSchema — what an extension may carry, the #4001 silently-dropped history, 'Fields to add/override', priority wins-on-conflict; defineObjectExtension)", + "packages/metadata-protocol/src/protocol.ts (the NOT_OVERRIDABLE refusal text and escape hatch)", "packages/plugins/plugin-auth/src/managed-extension-fields.ts + managed-extension-fields.test.ts (the build-time collision gate — better-auth-managed objects ONLY; adopted by ADR-0126 §3 as Regime E prior art, NOT a generic gate)", "docs/adr/0126-packaged-metadata-customization-model.md §2-§3 (Regime E: additive contributions at package grain, base never edited, install-grain not per-org)", - "content/docs/capabilities/integrations.mdx:17 + content/docs/build-without-code.mdx:37 (the E1 docs claim this item grounds)", + "content/docs/capabilities/integrations.mdx + content/docs/build-without-code.mdx (the E1 docs claim this item grounds)", "cross-refs: platform-core.metadata-registry-serving (owns the overlay merge + round-trip steps), platform-core.metadata-authoring-roundtrip (the generic deny-side overlay gate), access-security.owd-save-gate (which layer answers which 4xx on /meta/object writes)", "#12438 (the scoped sweep this item lands from)" ], @@ -1659,9 +1659,9 @@ "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)", + "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)", "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)" + "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)" ], "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", @@ -1672,10 +1672,10 @@ "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", + "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); 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)", + "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); 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)" @@ -1708,7 +1708,7 @@ { "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)", + "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 rehydrate guard, flag write)", "evidence": "purge response + before/after/post-restart row reads" }, { @@ -1734,17 +1734,17 @@ ], "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)", + "packages/cli/src/commands/serve.ts (planMarketplaceWiring — the pure wiring decision; cloud arm vs offline arm, host-composed instances never replaced), (INSTALL_LOCAL/RUNTIME_CONFIG/MARKETPLACE_PROXY/CLOUD_CONNECTION identities + RUNTIME_CONFIG_OPTIONS + OFFLINE_CONTROL_PLANE and why '' vs 'off' must differ), (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; INSTALL_LOCAL_CAPABILITY = manage_metadata with the #8976 rationale; reseed/purge mounts; purge semantics; rehydrate skip on sampleDataPurged)", + "packages/cloud-connection/src/cloud-connection-route-ledger.ts (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 (inline artifact mode), (the POST to /api/v1/marketplace/install-local), (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", + "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", "ref": "#sweep-2026-08-30" } ] @@ -1819,7 +1819,7 @@ ], "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)", + "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 and MarketplacePage.tsx (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)", @@ -1891,8 +1891,8 @@ ], "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/cloud-connection/src/runtime-config-plugin.ts (the payload contract in the header; features.marketplace derived from the live route table #8356+; features.installLocal derived with the option as ceiling #8388+; branding key resolution; telemetry.errorReporting #12681)", + "packages/cloud-connection/src/cloud-connection-route-ledger.ts (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)" @@ -1982,10 +1982,10 @@ ], "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/App.tsx (/docs → DocsLayout with index/:slug/:slug/:name children) + AppContent.tsx,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)", + "objectui packages/app-shell/src/layout/UnifiedSidebar.tsx (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)" ], @@ -2076,8 +2076,8 @@ "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/urlParams.ts (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; readFormRecordTarget create/edit/refuse — 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)" ], @@ -2159,10 +2159,10 @@ ], "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/chrome/KeyboardShortcutsDialog.tsx (the advertised groups — the full inventory this item probes), (the `?` handler with the input/textarea/contentEditable guard; `?` is the ONLY key it handles), (?shortcuts=1 via useUrlOverlay)", + "objectui packages/app-shell/src/context/CommandPaletteProvider.tsx (⌘/Ctrl+K — wired)", + "objectui packages/components/src/ui/sidebar.tsx (SIDEBAR_KEYBOARD_SHORTCUT = 'b'), (the handler requires metaKey||ctrlKey — the source of the B-vs-⌘B display mismatch)", + "objectui packages/app-shell/src/layout/chatDockState.ts (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)" ], @@ -2245,9 +2245,9 @@ ], "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 packages/app-shell/src/layout/UnifiedSidebar.tsx (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 (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 (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)" ], @@ -2325,7 +2325,7 @@ { "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", + "verify": "read the enum at packages/spec/src/data/object.zod.ts and the engine set at packages/objectql/src/engine.ts; the enumSource pin below fails this item loudly when the spec grows a sixth class", "evidence": "both source excerpts" } ], @@ -2354,18 +2354,18 @@ "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)", + "packages/objectql/src/lifecycle/lifecycle-service.ts (the ADR-0057 enforcer: header contract — reaper/rotator/archiver, the never-hot-delete-unarchived safety rule, #5194 bounds; SYSTEM_CTX; DEFAULT_LIFECYCLE_SWEEP_MS 3_600_000; DEFAULT_LIFECYCLE_INITIAL_DELAY_MS 60_000; REAP_BATCH_SIZE 500 / REAP_MAX_BATCHES_PER_SWEEP 20; enabled getter incl. OS_LIFECYCLE_DISABLED; start()/timer; archive safety; the aggregate sweep line)", + "packages/objectql/src/plugin.ts (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), (armed at start), (#4747 disarm on destroy)", + "packages/objectql/src/engine.ts (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 (LifecycleClassSchema, 5 members)", + "packages/services/service-automation/src/sys-automation-run.object.ts (telemetry 30d onlyWhen terminal — the MIXED-table rationale in its own comment), packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts (archive 90d/7y + the never-hot-delete note), packages/services/service-messaging/src/objects/inbox-message.object.ts (transient), http-delivery.object.ts (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", + "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", "ref": "#sweep-2026-08-30" } ] @@ -2428,13 +2428,13 @@ { "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", + "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 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", + "verify": "SeedMode members at seed.zod.ts; the resolution at metadata-protocol/src/seed-loader.ts; the runtime re-export shim proving one implementation", "evidence": "the three source excerpts" } ], @@ -2458,10 +2458,10 @@ }, "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/spec/src/data/seed.zod.ts (SeedMode — the five modes with their declared semantics, incl. replace's 'Delete ALL records … Dangerous' claim)", + "packages/metadata-protocol/src/seed-loader.ts (mode = dataset.mode || config.defaultMode), (existing-records preload for upsert/update/ignore ONLY — insert/replace never match), (writeRecord per-mode switch; the replace arm's bare insert + 'caller should have cleared the table' comment), (decideWriteAction — the batched mirror, replace → insert),+ (isNoOpReplay — seed-declared fields only, conservative on doubt)", + "packages/spec/src/data/seed-loader.zod.ts (defaultMode default 'upsert')", + "packages/metadata-protocol/src/protocol.ts+ (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)" @@ -2523,8 +2523,8 @@ ], "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 packages/app-shell/src/chrome/ThemeProvider.tsx (storageKey 'vite-ui-theme', initial read), (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), (setTheme persists then applies)", + "objectui packages/app-shell/src/layout/ModeToggle.tsx (the three-way menu with checkmarks) + AppHeader.tsx (mounted in the header)", "objectui#2942 (the auto-class regression this item's last step re-probes)" ], "history": [ @@ -2590,9 +2590,9 @@ ], "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 apps/console/src/main.tsx (branded favicon swap; generatePWAManifest from runtime branding → blob URL; the static manifest link removed and replaced)", + "objectui packages/app-shell/src/layout/AppHeader.tsx (useOffline), (the Offline pill + the ConnectionStatus mount)", + "objectui packages/app-shell/src/layout/ConnectionStatus.tsx (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)" ], diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index ae5394b823..c46fa20297 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -6,13 +6,13 @@ "$comment": "AREA-LEVEL standing fixtures. An item opts in with fixtures.provisioning = { use: \"\", why: \"…\" }; the recipe is written ONCE here so several items do not carry drifting copies of the same sequence. Recipes are runtime-provisioned (no repo file is touched, nothing to seed, nothing to un-seed beyond the teardown line) and every call below is grounded in framework source at the cited file:line — replay them literally, and if one 4xxs, re-read the citation before assuming the recipe rotted.", "qa-feeds-disabled": { "title": "Scratch feeds-DISABLED parent (qa_nofeeds) — the FEEDS_DISABLED probe target", - "why": "Stock showcase declares NO object with `enable.feeds: false`. Feeds is opt-OUT (spec default true, packages/spec/src/data/object.zod.ts:290), so every showcase object has comments ON and there is no naturally-occurring disabled parent to borrow — the opposite of the files case, where opt-IN (default false) leaves most objects files-disabled incidentally and attachments-storage could probe showcase_account before qa_nofiles existed. Without this recipe BOTH FEEDS_DISABLED clauses of record-discussion-mentions are blocked(fixture) and the code survives in that item's `negative` list alone — named but never driven, which is the gap #12118 was filed against.", + "why": "Stock showcase declares NO object with `enable.feeds: false`. Feeds is opt-OUT (spec default true, packages/spec/src/data/object.zod.ts), so every showcase object has comments ON and there is no naturally-occurring disabled parent to borrow — the opposite of the files case, where opt-IN (default false) leaves most objects files-disabled incidentally and attachments-storage could probe showcase_account before qa_nofiles existed. Without this recipe BOTH FEEDS_DISABLED clauses of record-discussion-mentions are blocked(fixture) and the code survives in that item's `negative` list alone — named but never driven, which is the gap #12118 was filed against.", "provenance": "#12118 — the gap was measured from PR #12116 (the files-side twin, which added the FILES_DISABLED update-verb probe and reported this one rather than widening). Recipe shape copied from qa-scratch-authz in areas/attachments-storage.json (#7716/#7670) rather than invented.", "app": "showcase", "requires": [ - "an admin session that holds the `manage_metadata` capability — PUT /api/v1/meta/:type/:name is capability-gated per ADR-0066 D1 (#6603), so a bare authenticated session is NOT enough (packages/rest/src/rest-route-ledger.ts:174, the `PUT /api/v1/meta/:type/:name` row)", + "an admin session that holds the `manage_metadata` capability — PUT /api/v1/meta/:type/:name is capability-gated per ADR-0066 D1 (#6603), so a bare authenticated session is NOT enough (packages/rest/src/rest-route-ledger.ts, the `PUT /api/v1/meta/:type/:name` row)", "an isolated boot (own port + file DB, dogfood skill §0) — this recipe authors metadata into the running app", - "AuditPlugin active in the boot: it owns sys_comment AND registers the enable.feeds capability gate (packages/plugins/plugin-audit/src/audit-writers.ts:1456-1457). A boot without it accepts every comment and both probes go green for the wrong reason — confirm the gate is live before scoring either clause." + "AuditPlugin active in the boot: it owns sys_comment AND registers the enable.feeds capability gate (packages/plugins/plugin-audit/src/audit-writers.ts). A boot without it accepts every comment and both probes go green for the wrong reason — confirm the gate is live before scoring either clause." ], "sequence": [ { @@ -28,7 +28,7 @@ "enableOnInstall": true }, "expect": "2xx with the installed package echoed back. A re-run against a live DB 409s on the duplicate id — send `overwrite: true` deliberately rather than adding it by reflex (the guard exists so a second install never silently clobbers the first).", - "source": "POST /api/v1/packages is the dispatcher install route (packages/rest/src/rest-route-ledger.ts:312 note; client `packages.install`); the body shape { manifest, enableOnInstall?, overwrite? } is URL- and body-pinned in packages/client/src/client.test.ts:2144-2166" + "source": "POST /api/v1/packages is the dispatcher install route (packages/rest/src/rest-route-ledger.ts note; client `packages.install`); the body shape { manifest, enableOnInstall?, overwrite? } is URL- and body-pinned in packages/client/src/client.test.ts" }, { "step": 2, @@ -48,11 +48,11 @@ } } }, - "expect": "2xx. `enable.feeds` is stated EXPLICITLY false — unlike files it cannot be omitted, because omission means the spec default `true` (object.zod.ts:290) and would give a feeds-ENABLED object that scores both probes green for the wrong reason. `sharingModel` is public_read_write ON PURPOSE: the probes must be refused by the CAPABILITY gate, not by record access, and a caller who can read and edit this parent isolates FEEDS_DISABLED from the sibling RECORD_NOT_ACCESSIBLE refusal (packages/plugins/plugin-audit/src/comment-access-hooks.ts:132).", - "source": "`enable.feeds` is the #2707 opt-out, default true (packages/spec/src/data/object.zod.ts:290: 'Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike'). `sharingModel` is a TOP-LEVEL object key, enum ['private','public_read','public_read_write','controlled_by_parent'] (packages/spec/src/data/object.zod.ts:2144; ADR-0090 D4). `?package=` is read as `query.package` and threaded to saveMetaItem as packageId (packages/runtime/src/domains/meta.ts)" + "expect": "2xx. `enable.feeds` is stated EXPLICITLY false — unlike files it cannot be omitted, because omission means the spec default `true` (object.zod.ts) and would give a feeds-ENABLED object that scores both probes green for the wrong reason. `sharingModel` is public_read_write ON PURPOSE: the probes must be refused by the CAPABILITY gate, not by record access, and a caller who can read and edit this parent isolates FEEDS_DISABLED from the sibling RECORD_NOT_ACCESSIBLE refusal (packages/plugins/plugin-audit/src/comment-access-hooks.ts).", + "source": "`enable.feeds` is the #2707 opt-out, default true (packages/spec/src/data/object.zod.ts: 'Record comments/collaboration feed. Default on; explicit false hides the feed UI and rejects any write that makes a comment target this object (403 FEEDS_DISABLED) — a new comment and an update that re-threads an existing one alike'). `sharingModel` is a TOP-LEVEL object key, enum ['private','public_read','public_read_write','controlled_by_parent'] (packages/spec/src/data/object.zod.ts; ADR-0090 D4). `?package=` is read as `query.package` and threaded to saveMetaItem as packageId (packages/runtime/src/domains/meta.ts)" } ], - "teardown": "DELETE /api/v1/packages/com.objectstack.qa.feeds (client `packages.uninstall`, ledgered at rest-route-ledger.ts:317) — or simply discard the isolated file DB, which is the cheaper path and the one an isolated boot makes free. Any throwaway qa_nofeeds record and any sys_comment row created against it go with the package.", + "teardown": "DELETE /api/v1/packages/com.objectstack.qa.feeds (client `packages.uninstall`, ledgered at rest-route-ledger.ts) — or simply discard the isolated file DB, which is the cheaper path and the one an isolated boot makes free. Any throwaway qa_nofeeds record and any sys_comment row created against it go with the package.", "knownGaps": [ "The recipe lands the OBJECT only. Each probe inserts its own throwaway qa_nofeeds record inline (it needs that record's id in the thread_id), exactly as attachments-storage's clause 5 does with qa_nofiles — no standing rows here.", "This recipe provisions the SERVER-side probe target. The `feeds:false must HIDE the panel` half of the negative is a console/objectui assertion: to score it, navigate to the qa_nofeeds record detail and confirm no discussion panel mounts and no sys_comment fetch is issued. If the console has no route for a scratch object in the running build, record that half blocked(fixture) and score the two API clauses — do not infer the UI half from the API refusal." @@ -161,7 +161,7 @@ "examples/app-showcase/src/data/objects/account.object.ts (requiredness + format/conditional validations)", "packages/runtime/src/route-ledger.ts (/data CRUD routes)", "packages/rest/src/rest-server.ts (POST /data/:object/:id/clone → registerDataActionEndpoints) + packages/metadata-protocol/src/protocol.ts (cloneData: enable.clone gate, findOne-in-caller-context, CLONE_STRIP_FIELDS, stripReadonlyForInsert)", - "packages/rest/src/rest-route-ledger.ts:122 (POST /api/v1/data/:object/:id/clone, client data.clone)", + "packages/rest/src/rest-route-ledger.ts (POST /api/v1/data/:object/:id/clone, client data.clone)", "objectui: e2e/live/record-history-display.spec.ts", "cross-ref: the inline-edit atomic two-surface behavior (ONE Save bar / ONE PATCH carrying exactly the changed keys + ifMatch) is folded into records-forms.concurrent-edit-conflict, not here" ], @@ -1579,7 +1579,7 @@ "ref": "objectui: e2e/live/list-row-action-cel.spec.ts (row-level CEL visibility) + e2e/live/action-modal.spec.ts (dialog dispatch)" }, "source": [ - "packages/spec/src/ui/action.zod.ts:565 (ACTION_LOCATIONS — the canonical 6-value enum, single source of truth; `global_nav` was the 7th until #6888 retired it — no product surface rendered it)", + "packages/spec/src/ui/action.zod.ts (ACTION_LOCATIONS — the canonical 6-value enum, single source of truth; `global_nav` was the 7th until #6888 retired it — no product surface rendered it)", "examples/app-showcase/src/ui/actions/index.ts (per-location fixture fleet + the record:quick_actions filter note + the two headless `locations: []` declarations)", "packages/lint/src/validate-action-locations.ts (the `action-no-placement` rule — this repo's codification of the current contract: an action with no `locations` renders nowhere and is therefore inert, while `locations: []` is the deliberate headless declaration and is NOT flagged)", "objectui: packages/types/src/ui-action.ts `actionRendersAt` (the single placement predicate — a membership test, so an undeclared or empty `locations` matches nothing) + packages/.../action-bar.tsx, its consumer since objectui#3142", @@ -1638,10 +1638,10 @@ "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 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)" + "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)" ], "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" + "no showcase rule declares severity:'info' — it shares the identical non-error branch with 'warning' (rule-validator.ts 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": [ @@ -1651,7 +1651,7 @@ "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", - "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)" + "severity — 'error' (the default, validation.zod.ts) blocks; 'warning'/'info' rules that MATCH do not block: the write lands and the message is logged server-side (rule-validator.ts 'only error blocks')" ], "steps": [ "boot showcase isolated; sign in as admin", @@ -1689,22 +1689,22 @@ { "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", + "verify": "the 2xx response + the persisted re-read + the logger.warn line (packages/objectql/src/validation/rule-validator.ts — 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", - "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", + "the fail-closed posture is SEVERITY-SCOPED: an unevaluable 'warning'/'info' rule stays non-blocking (logged, never thrown — rule-validator.ts '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; :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)", + "packages/spec/src/data/validation.zod.ts (ValidationRuleSchema, 6 discriminated variants; severity enum ['error','warning','info'] default 'error')", + "packages/objectql/src/validation/rule-validator.ts (evaluateRule switch + per-type codes; 'only error blocks', unevaluable-advisory stays non-blocking, the severity fork)", + "examples/app-showcase/src/data/objects/{account,project,task}.object.ts (the seeded rules; project.object.ts the advisory project_health_progression)", "#1475 (declared ≠ enforced history: 9 declared → 6 declared+enforced)" ], "history": [ @@ -1717,7 +1717,7 @@ { "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", + "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 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" } ] @@ -2351,10 +2351,10 @@ "as the mentioned second user, open the notification bell — it gains the mention notification with a deep link back to the record; unreadCount increments; mark-as-read drops it", "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", + "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): 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", "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" + "OPEN-VOCABULARY probe — plant an author-EXTENDED activity type: sys_activity narrows apiMethods to ['get','list'] (sys-activity.object.ts), 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": [ { @@ -2390,19 +2390,19 @@ { "clause": "a NEW comment targeting a feeds-DISABLED parent is refused authoritatively: POST /api/v1/data/sys_comment with a qa_nofeeds thread_id answers 403 with code FEEDS_DISABLED, and no sys_comment row lands — the refusal is the oracle, and a silent no-op that returns 2xx while writing nothing is equally a FAIL", "oracle": "api", - "verify": "the POST returns status 403 and a body whose `code` is exactly \"FEEDS_DISABLED\" (plugin-audit enforceFeedsCapability throws { code: 'FEEDS_DISABLED', status: 403, object } and the REST layer forwards .status verbatim — packages/plugins/plugin-audit/src/audit-writers.ts:1440-1456), AND a follow-up GET /api/v1/data/sys_comment filtered by that thread_id returns ZERO rows. The same call against the feeds-ENABLED control parent must return 2xx, or the 403 is not attributable to the capability gate", + "verify": "the POST returns status 403 and a body whose `code` is exactly \"FEEDS_DISABLED\" (plugin-audit enforceFeedsCapability throws { code: 'FEEDS_DISABLED', status: 403, object } and the REST layer forwards.status verbatim — packages/plugins/plugin-audit/src/audit-writers.ts), AND a follow-up GET /api/v1/data/sys_comment filtered by that thread_id returns ZERO rows. The same call against the feeds-ENABLED control parent must return 2xx, or the 403 is not attributable to the capability gate", "evidence": "the 403 response body showing the code, the zero-row thread read, and the 2xx control" }, { "clause": "RE-THREADING an existing comment onto a feeds-disabled parent is refused identically — the flag is a property of the TARGET OBJECT, not of the verb that made a row point at it (#10170) — and the stored thread_id is UNCHANGED after the refusal", "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", + "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), 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", + "verify": "after each screenshot settles, the rendered rows against the mode's filter arm (objectui RecordActivityTimeline.tsx filterItems; the four options offered are getFilterOptions). The mode TYPE is imported from @objectstack/spec/data, 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" }, { @@ -2417,7 +2417,7 @@ "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), 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": [ @@ -2441,14 +2441,14 @@ "objectui: packages/plugin-detail/src/{RecordChatterPanel,CommentInput,MentionAutocomplete,extractMentions}.tsx (compose + @mention)", "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/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)" + "framework: packages/spec/src/data/object.zod.ts (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 (enforceFeedsCapability, registered on beforeInsert AND beforeUpdate per #10170), packages/plugins/plugin-audit/src/comment-access-hooks.ts (RECORD_NOT_ACCESSIBLE — the sibling refusal the probes isolate against), packages/plugins/plugin-audit/src/objects/sys-comment.object.ts (thread_id is conventionally `{object}:{record_id}`)", + "framework: packages/spec/src/data/feed.zod.ts (FeedFilterMode, 4 members — pinned above),+ (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 (apiMethods ['get','list'] — why the probe cannot direct-POST)", + "objectui: packages/plugin-detail/src/RecordActivityTimeline.tsx (FeedFilterMode imported from the spec), (the four filter options), (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", - "ref": "The FEEDS_DISABLED clauses (6, 7) have engine/API pins that predate this item's coverage of them: packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts:341 (describe '[#10170] enable.feeds is asked on the UPDATE verb too' — by-id and predicate re-thread onto `lead_walled`, plus the insert-verb control at :206 and the absent-thread_id boundary at :393) and packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts:317 (the create side over real REST against the `cmt_nofeeds` fixture, packages/qa/dogfood/test/fixtures/comments-fixture.ts:69-76). ⚠️ Those pins run against their OWN fixtures and the objectql/REST seams — they do NOT reach the console half of the negative (panel hidden, sys_comment fetch skipped), and they never exercised the showcase surface, which is why clauses 6 and 7 still carry their own steps and the qa-feeds-disabled recipe. The remaining clauses (1-5: optimistic reconcile, activity interleave, mention bell) are browser-only and have no pin." + "ref": "The FEEDS_DISABLED clauses (6, 7) have engine/API pins that predate this item's coverage of them: packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts (describe '[#10170] enable.feeds is asked on the UPDATE verb too' — by-id and predicate re-thread onto `lead_walled`, plus the insert-verb control and the absent-thread_id boundary) and packages/qa/dogfood/test/comments-permission-matrix.dogfood.test.ts (the create side over real REST against the `cmt_nofeeds` fixture, packages/qa/dogfood/test/fixtures/comments-fixture.ts). ⚠️ Those pins run against their OWN fixtures and the objectql/REST seams — they do NOT reach the console half of the negative (panel hidden, sys_comment fetch skipped), and they never exercised the showcase surface, which is why clauses 6 and 7 still carry their own steps and the qa-feeds-disabled recipe. The remaining clauses (1-5: optimistic reconcile, activity interleave, mention bell) are browser-only and have no pin." }, "history": [ { @@ -2466,7 +2466,7 @@ { "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", + "change": "added the feed-filter axis: the four FeedFilterMode values (spec feed.zod.ts) 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 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" } ] @@ -2650,7 +2650,7 @@ "source": [ "objectui: packages/app-shell/src/views/CreateViewDialog.tsx (kanban groupByField), packages/plugin-view/src/ManageViewsDialog.tsx (rename/default/pin/delete affordances, read-only suppression)", "objectui: packages/app-shell/src/views/ObjectView.tsx (updateView/deleteView wiring), packages/data-objectstack/src/index.ts (createView/updateView/deleteView → meta.saveItem/deleteItem)", - "framework: packages/rest/src/rest-route-ledger.ts:94-95 (PUT/DELETE /api/v1/meta/:type/:name)", + "framework: packages/rest/src/rest-route-ledger.ts (PUT/DELETE /api/v1/meta/:type/:name)", "PENDING-GAPS §C saved-view-management; cross-ref studio-authoring.view-authoring-live (distinct — this is the runtime end-user/admin lifecycle)" ], "history": [ @@ -3006,7 +3006,7 @@ "ref": "objectui: e2e/import-console/import-console-undo.spec.ts, e2e/import-harness/import-undo.spec.ts (both self-gate)" }, "source": [ - "framework: packages/rest/src/rest-route-ledger.ts:124-129 (POST /data/:object/import/jobs; /import/jobs/:jobId/{cancel,undo,results}; GET /import/jobs[/:jobId])", + "framework: packages/rest/src/rest-route-ledger.ts (POST /data/:object/import/jobs; /import/jobs/:jobId/{cancel,undo,results}; GET /import/jobs[/:jobId])", "objectui: e2e/import-console/import-console-undo.spec.ts, e2e/import-harness/import-undo.spec.ts", "PENDING-GAPS §C import-job-undo-cancel" ], @@ -3042,7 +3042,7 @@ "the four seeded showcase hooks (examples/app-showcase/src/data/hooks/index.ts, bound via defineStack({ hooks: allHooks })): showcase_normalize_task_title (showcase_task, events ['beforeInsert','beforeUpdate'], trims title, priority 50, onError:'abort'); showcase_stamp_inquiry_defaults (showcase_inquiry, beforeInsert, stamps status='new'/source='web', onError:'abort'); showcase_audit_task_completion (showcase_task, afterUpdate, condition previous.done!=true && record.done==true, async, retryPolicy {maxRetries:3,backoffMs:1000}, capabilities:['log'], onError:'log', priority 90); showcase_warn_over_budget (showcase_project, afterUpdate, condition record.spent!=null && record.budget!=null && record.spent>record.budget, async, capabilities:['log'], onError:'log')", "showcase_task (title required text + done boolean, defaultValue false), showcase_inquiry (status select whose 'new' option is default:true + source plain text with NO default), showcase_project (budget/spent currency + the spent_within_budget script rule that REJECTS spent > budget*1.2)", "seeded rows: not-done task 'Build homepage' (done:false) for the transition PATCH; already-done tasks 'Audit current IA' and 'App wireframes' (done:true) for the non-transition proof; projects 'Data Platform' (budget 600000 / spent 420000) and 'Website Relaunch' (budget 150000 / spent 60000)", - "server log capture — the async audit/warn bodies call ctx.log.info/warn, routed to the engine logger (packages/runtime/src/sandbox/body-runner.ts:321 log: engineCtx.logger); AND the ability to register a scratch hook (throwing / ordered) for the variants the four fixtures cannot observe", + "server log capture — the async audit/warn bodies call ctx.log.info/warn, routed to the engine logger (packages/runtime/src/sandbox/body-runner.ts log: engineCtx.logger); AND the ability to register a scratch hook (throwing / ordered) for the variants the four fixtures cannot observe", "a real running server: hooks fire in the ObjectQL engine on the live /api/v1/data/* route, never on a simulated dispatch" ], "knownGaps": [ @@ -3065,7 +3065,7 @@ "CEL condition — total-record `!= null` guard, NOT has() (#4770) — FIXTURE: showcase_warn_over_budget `record.spent != null && record.budget != null && ...`; because `record` is made total over declared fields, `has(record.spent)` is uniformly TRUE even when the field holds null, so only `!= null` keeps `null > null` (which CEL has no overload for) from aborting the expression — an abort #4775 would turn into a REJECTED write", "async fire-and-forget (after* ONLY) — FIXTURE: showcase_audit_task_completion + showcase_warn_over_budget (async:true); the wrapper ignores async on before* events (hook-wrappers.ts: fireAndForget = Boolean(meta.async) && isAfterEvent)", "retryPolicy {maxRetries,backoffMs} — FIXTURE declares it: showcase_audit_task_completion {maxRetries:3, backoffMs:1000}; observing an actual RETRY needs a transiently-failing scratch body", - "capabilities:['log'] (L2 sandbox log capability) — FIXTURE: audit + warn bodies call ctx.log.info/warn wired to the engine logger (body-runner.ts:321)", + "capabilities:['log'] (L2 sandbox log capability) — FIXTURE: audit + warn bodies call ctx.log.info/warn wired to the engine logger (body-runner.ts)", "onError:'abort' (rollback) — FIXTURE declares it on showcase_normalize_task_title / showcase_stamp_inquiry_defaults; observing the ROLLBACK needs a THROWING before* scratch hook (the fixtures never throw)", "onError:'log' (tolerate + continue) — FIXTURE declares it on showcase_audit_task_completion / showcase_warn_over_budget; observing the SUPPRESSION needs a throwing async scratch (an async failure never rolls the triggering write back regardless)", "priority ordering (lowest-first, same object+event) — SCRATCH/knownGap: no two showcase hooks share object+event, so register two scratch hooks on one object+event with priorities e.g. 10 and 90 and read the execution order from the log", @@ -3167,7 +3167,7 @@ "packages/spec/src/data/hook.zod.ts (HookSchema + HookEvent enum beforeFind/afterFind/beforeInsert/afterInsert/beforeUpdate/afterUpdate/beforeDelete/afterDelete; defineHook; async 'after* only'; onError default 'abort'; empty-target refusal #4001)", "packages/objectql/src/hook-wrappers.ts (wrapDeclarativeHook wrapping order condition→async→retry→timeout→onError; pickRecordPayload #4770 record = stored ⊕ payload total over declared fields; pickPreviousPayload #4784 previous binding; HookConditionError #4775 fail-closed, raised OUTSIDE onError; fireAndForget = async && isAfterEvent)", "packages/objectql/src/hook-binder.ts (bindHooksToEngine: per-event × per-object engine.registerHook with priority; unresolved-body / empty-target skips)", - "packages/runtime/src/sandbox/body-runner.ts:321 (log: engineCtx.logger — the ['log'] capability routing that makes the audit/warn lines an observable log oracle)", + "packages/runtime/src/sandbox/body-runner.ts (log: engineCtx.logger — the ['log'] capability routing that makes the audit/warn lines an observable log oracle)", "packages/spec/liveness/hook.json (object/events/body/priority/async/condition/retryPolicy/timeout/onError all 'live'; label/description 'dead' but kept as docs)", "examples/app-showcase/src/data/objects/{task,inquiry,project}.object.ts (task.done boolean + task.title required; inquiry.status select['new' default:true]/source text no-default; project.budget/spent currency + spent_within_budget rule rejecting spent > budget*1.2)", "examples/app-showcase/src/data/seed/index.ts (done tasks 'Audit current IA'/'App wireframes'; not-done 'Build homepage'; projects 'Data Platform' 600000/420000 and 'Website Relaunch' 150000/60000)", @@ -3539,8 +3539,8 @@ "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" + "an admin session holding `manage_metadata` (PUT /api/v1/meta/:type/:name is capability-gated, packages/rest/src/rest-route-ledger.ts) 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 and semantic-zoo.object.ts — 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", @@ -3560,23 +3560,23 @@ { "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", + "verify": "PUT + GET round-trip of qa_group_gate shows the normalized envelope (ExpressionInputSchema, packages/spec/src/data/object.zod.ts); pinned by object-strictness-batch20.test.ts", "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", + "verify": "the 4xx body carries the KEY_GUIDANCE text (object.zod.ts); 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", + "verify": "the visibleWhen-passthrough describe in packages/spec/src/data/field-group-layout.test.ts is green; passthrough ruled at field-group-layout.ts", "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)", + "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 (deriveFieldGroupSections copies key/label/collapse only) and plugin-detail/src/synth/buildDefaultPageSchema.ts (deriveFieldGroupDetailSections likewise) — so an authored predicate is silently INERT in the console: the spec's 'declared = enforced on day one' claim (object.zod.ts) 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" @@ -3584,14 +3584,14 @@ { "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)", + "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 #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", + "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) while the renderer machinery the section-gating contract lives in documents fail-OPEN with a named diagnostic (objectui TabbedForm.tsx '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": [ @@ -3600,17 +3600,17 @@ "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)", + "packages/spec/src/data/object.zod.ts (visibleOn tombstone prescription), (visibleWhen, CEL via ExpressionInputSchema — #13030, commit 53dc739, Option-A maintainer ruling 2026-08-28)", + "packages/spec/src/data/field-group-layout.ts (verbatim passthrough; evaluation ruled the renderer's), field-group-layout.test.ts+ and object-strictness-batch20.test.ts (the pins)", + "objectui: packages/plugin-form/src/fieldGroups.ts and packages/plugin-detail/src/synth/buildDefaultPageSchema.ts — the two adapters that DROP the key at objectui 1e14d70 (clause 4's expected-fail); packages/components/src/renderers/form/form.tsx (#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, semantic-zoo.object.ts (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", + "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/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" } ] @@ -3630,13 +3630,13 @@ "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)" + "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 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" + "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)", + "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), plus the engine's 'Autonumber collided — re-seeding' warn line if one occurs naturally" ] }, "steps": [ @@ -3651,37 +3651,37 @@ { "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)", + "verify": "the refusal body + a post-refusal count; wire identity single-sourced at packages/drivers/driver-memory/src/memory-unique-constraint.ts (uniqueViolationError)", "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)", + "verify": "both no-sku POSTs return 2xx and both rows read back (module note memory-unique-constraint.ts; pin)", "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)", + "verify": "the PATCH refusal + the unchanged re-read; the self-update 2xx (pins memory-unique-constraint.test.ts,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", + "verify": "post-refusal re-reads of every matched row are byte-identical to before (memory-driver.ts,627-631 'Prepare and CHECK every row before mutating any of them'; pin). 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", + "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), 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)", + "verify": "memory-unique-constraint.test.ts (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", + "verify": "memory-unique-constraint.test.ts ('the refusal is recognisable to the ENGINE, so the autonumber resync converges'); engine path createWithAutonumberResync, packages/objectql/src/engine.ts", "evidence": "test output" } ], @@ -3700,10 +3700,10 @@ }, "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)" + "packages/drivers/driver-memory/src/memory-driver.ts (updateMany check-before-mutate)", + "packages/drivers/driver-sql/src/schema-drift.ts (uniqueIndexesFromFields, ADR-0120 D1/D3)", + "packages/objectql/src/engine.ts (createWithAutonumberResync — the branch the enforcement makes reachable)", + "content/docs/capabilities/data.mdx ('Uniqueness … per-field switches' — the docs claim this item makes testable)" ], "history": [ { @@ -3728,10 +3728,10 @@ "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" + "cascade: showcase_invoice_line.invoice (master_detail, explicit deleteBehavior:'cascade', invoice.object.ts) and expense-report lines (expense-report.object.ts)", + "set_null scalar: showcase_contact.account (optional lookup, default set_null — contact.object.ts)", + "multi-value: showcase_field_zoo.f_lookups (lookup multiple:true → showcase_account, field-zoo.object.ts)", + "required-FK escalation: showcase_invoice.account (lookup required:true with DEFAULT deleteBehavior — invoice.object.ts): 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", @@ -3745,7 +3745,7 @@ "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" + "NOT enumSource-pinnable: deleteBehavior is an inline z.enum(['set_null','cascade','restrict']) inside FieldSchema (field.zod.ts) 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)", @@ -3760,37 +3760,37 @@ { "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)", + "verify": "post-delete re-read of the contact shows account: null (defaults ruled at packages/objectql/src/engine.ts)", "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)", + "verify": "f_lookups re-read shows exactly the surviving account id (#9438; empty-array-as-no-link semantics engine.ts)", "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')", + "verify": "post-delete line list returns 0 rows (recursion ruled at engine.ts '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", + "verify": "the refusal body against engine.ts (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)", + "verify": "DELETE of a referenced account answers 409 DELETE_RESTRICTED (escalation engine.ts: 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)", + "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)", "evidence": "the parse refusal + the pin's output" } ], @@ -3798,7 +3798,7 @@ "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)" + "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)" ], "traps": [ "destructive-in-place", @@ -3809,9 +3809,9 @@ "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)" + "packages/spec/src/data/field.zod.ts (deleteBehavior inline enum; #9689 parse rejection, #9784 default materialization)", + "packages/objectql/src/engine.ts (defaults, coercion + [cascade-delete] log, required-FK escalation incl. #9688 multi-value deferral), (DELETE_RESTRICTED envelope, #12166 count disclosure, cascade recursion)", + "examples/app-showcase/src/data/objects/invoice.object.ts,184; contact.object.ts; field-zoo.object.ts; expense-report.object.ts (the four stock arms)" ], "history": [ { @@ -3856,11 +3856,11 @@ { "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", + "verify": "the qa_noclone refusal ({code:'CLONE_DISABLED', status:403} thrown at packages/metadata-protocol/src/protocol.ts) + 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", + "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), 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" @@ -3868,11 +3868,11 @@ { "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", + "verify": "top-level keys of the raw response against the rest-route-ledger row (packages/rest/src/rest-route-ledger.ts) — 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", + "clause": "internal fields never ride the 201: omitInternalFieldsFromWriteResponse runs on the insert result (#7823, protocol.ts) — 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)" @@ -3880,7 +3880,7 @@ { "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", + "verify": "both refusal envelopes; protocol.ts", "evidence": "the two refusals" } ], @@ -3888,19 +3888,19 @@ "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" + "NOTE for the run record, not a clause: 'one-click record cloning' (content/docs/capabilities/integrations.mdx) 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). 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" + "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" }, "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)", + "packages/metadata-protocol/src/protocol.ts (cloneData: registration gate #3770, CLONE_DISABLED, findOne-in-context, CLONE_STRIP_FIELDS + system/autonumber/formula/summary strip, overrides, stripReadonlyForInsert #3043, omitInternalFieldsFromWriteResponse #7823)", + "packages/rest/src/rest-route-ledger.ts (POST /api/v1/data/:object/:id/clone, client data.clone, bare-201 note #11924)", + "objectui: packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx (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": [ @@ -3926,8 +3926,8 @@ "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" + "showcase_account: enable.trackHistory:true (account.object.ts) + per-field trackHistory on industry/status — the BOTH-halves fixture", + "showcase_task: per-field trackHistory on status/priority (task.object.ts,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" @@ -3944,7 +3944,7 @@ { "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", + "verify": "the sys_activity read shows the old→new summary with display values (renderTrackedChangeSummary, packages/plugins/plugin-audit/src/audit-writers.ts; #7230 label + #7289 option-value localization); the timeline renders the same row", "evidence": "the sys_activity row + a timeline screenshot" }, { @@ -3956,24 +3956,24 @@ { "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)", + "verify": "screenshot both detail pages after settle; gating ruled at objectui RecordDetailView.tsx (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 })", + "verify": "GET /api/v1/auth/me/permissions apiOperations (or a 405 allowed array) — derivation ruled at packages/spec/src/data/api-derivation.ts ({ 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)", + "verify": "packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts green (planTrackedLookupReads audit-writers.ts, resolution); 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)", + "an untracked edit producing an old→new diff row is a FAIL — trackHistory is opt-in per field (spec default false, object.zod.ts 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" @@ -3987,10 +3987,10 @@ "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" + "packages/plugins/plugin-audit/src/audit-writers.ts (planTrackedLookupReads), (renderTrackedChangeSummary — ADR-0052 §5b, #7230/#7289 localization), (zero-reads-by-default plan)", + "packages/spec/src/data/object.zod.ts (history derives from get ∧ trackHistory), (opt-in default false); packages/spec/src/data/api-derivation.ts", + "objectui: packages/app-shell/src/views/RecordDetailView.tsx (History tab three-precondition gate)", + "examples/app-showcase/src/data/objects/account.object.ts (the deliberate both-halves fixture + its comment ruling the tab gate), task.object.ts,60" ], "history": [ { @@ -4015,12 +4015,12 @@ "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", + "the shipped mapping showcase_inquiry_feed (examples/app-showcase/src/data/mappings/index.ts) — 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" + "the unknown-transform runtime refusal (import-mapping.ts) 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": [ @@ -4042,17 +4042,17 @@ { "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", + "verify": "per-variant row re-reads against the switch arms in packages/rest/src/import-mapping.ts — 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", + "verify": "the refusal (import-mapping.ts — 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", + "clause": "lookup copies the raw value for DOWNSTREAM resolution: the transform itself moves the source value verbatim (import-mapping.ts '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" @@ -4060,14 +4060,14 @@ { "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", + "verify": "both responses against import-mapping.ts", "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", + "an unknown transform string reaching applyMappingToRows answers 400 UNSUPPORTED_TRANSFORM 'unknown transform' (import-mapping.ts) — 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": [ @@ -4080,9 +4080,9 @@ "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)", + "packages/spec/src/data/mapping.zod.ts (TransformType — none/constant/lookup/split/join/javascript/map, 7 members, named export pinned above)", + "packages/rest/src/import-mapping.ts (format guard + the javascript refusal naming #2611), (the per-transform switch + around-the-seam unknown-transform refusal)", + "examples/app-showcase/src/data/mappings/index.ts (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": [ diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index c8f4ce1dc2..08ef5a243c 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -580,7 +580,7 @@ "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", + "the showcase app's navigation, which declares ALL FOUR palette-grouped leaf types (examples/app-showcase/src/ui/apps/index.ts): object items (e.g. nav_projects/nav_tasks), page items (e.g. nav_capability_map), dashboard items (e.g. nav_ops → showcase_ops_dashboard), report items (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)" ], @@ -613,7 +613,7 @@ { "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", + "verify": "post-screenshot DOM shows the four headings with the showcase fixtures under the right ones (flattenNavigation + the per-type CommandGroup blocks, CommandPalette.tsx,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" }, { @@ -631,19 +631,19 @@ { "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)", + "verify": "the group lists exactly the stock active apps (showcase_app/setup/account shapes) with the 'current' marker on the active one (CommandPalette.tsx 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)", + "verify": "with the scratch fixture (knownGaps): the false-predicate item absent from every group, the true-predicate item present (CommandPalette.tsx 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)", + "verify": "post-select the SearchResultsPage route is loaded (CommandPalette.tsx)", "evidence": "the handoff capture" } ], @@ -656,9 +656,9 @@ ], "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/chrome/CommandPalette.tsx (the palette's declared scope: 'quick navigation across apps, objects, dashboards, pages, reports, and global actions'), (flattened nav corpus + evaluateVisibility filter + searchable-object whitelist), (the four per-type CommandGroups, onSelect → navigate(resolveHref…)), (Switch app, active !== false, current marker), (theme commands via setTheme), (Open full search → /search), (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)", + "examples/app-showcase/src/ui/apps/index.ts,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": [ diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index 4912ac8fb0..c4d49abf95 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -534,8 +534,8 @@ "the stock showcase artifact (artifact-backed objects like showcase_task and packaged views are the locked targets; a scratch name serves the allowed-create side)" ], "knownGaps": [ - "CORRECTION TO RUN #7637 (recorded by run #7695): the Studio read-only badge is HONEST on this build/pin — it is not asserting a lock the server declines to apply. With OS_METADATA_WRITABLE=permission set, GET /meta reports permission { allowOrgOverride: true, overrideSource: 'env' } (the enum is registry|env — packages/spec/src/api/protocol.zod.ts:208) and the editor becomes fully writable; the badge tracks the real writable computation, proven BOTH ways in that run. #7637's contrary observation does not reproduce here — do not act on it as written, and do not re-file it", - "ROUTE NOTE (run #7695 / #7690): a flow authored over the metadata API is NOT editable in Studio — it opens behind 'This flow is provided by an installed package, so it is read-only at runtime', with or without `?package=`. objectui's ResourceEditPage treats an item as artifact-backed when `layered.code != null && _packageId !== 'sys_metadata'` (packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx:951-955 / 1311-1323) and `flow` declares allowOrgOverride:false, so for THIS kind the banner is arguably telling the truth: the server would refuse the overlay write anyway. The editable path is the console's own create page. Recorded as a route note, NOT a defect — the polarity-reversed case (the same heuristic locking an object published into a WRITABLE package, which Studio and the server both treat as editable) IS the defect and is filed at objectui#4308; whoever works that card should confirm this case stays correct" + "CORRECTION TO RUN #7637 (recorded by run #7695): the Studio read-only badge is HONEST on this build/pin — it is not asserting a lock the server declines to apply. With OS_METADATA_WRITABLE=permission set, GET /meta reports permission { allowOrgOverride: true, overrideSource: 'env' } (the enum is registry|env — packages/spec/src/api/protocol.zod.ts) and the editor becomes fully writable; the badge tracks the real writable computation, proven BOTH ways in that run. #7637's contrary observation does not reproduce here — do not act on it as written, and do not re-file it", + "ROUTE NOTE (run #7695 / #7690): a flow authored over the metadata API is NOT editable in Studio — it opens behind 'This flow is provided by an installed package, so it is read-only at runtime', with or without `?package=`. objectui's ResourceEditPage treats an item as artifact-backed when `layered.code != null && _packageId !== 'sys_metadata'` (packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx / 1311-1323) and `flow` declares allowOrgOverride:false, so for THIS kind the banner is arguably telling the truth: the server would refuse the overlay write anyway. The editable path is the console's own create page. Recorded as a route note, NOT a defect — the polarity-reversed case (the same heuristic locking an object published into a WRITABLE package, which Studio and the server both treat as editable) IS the defect and is filed at objectui#4308; whoever works that card should confirm this case stays correct" ] }, "steps": [ @@ -596,8 +596,8 @@ "packages/objectql/src/overlay-precedence.test.ts ('denied — must throw 403 (not_overridable or not_creatable)')", "packages/metadata-protocol/src/protocol.ts (isRuntimeCreateAllowed — the write-gate authority)", "ADR-0005 (metadata customization opt-in), ADR-0049 (enforce-or-remove — the job rationale)", - "packages/spec/src/api/protocol.zod.ts:208 (overrideSource registry|env — how GET /meta reports an OS_METADATA_WRITABLE-granted override, the read that made the #7637 correction checkable)", - "objectui packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx:951-955, 1311-1323 (the artifact-backed heuristic behind the read-only banner; cross-linked to objectui#4308)" + "packages/spec/src/api/protocol.zod.ts (overrideSource registry|env — how GET /meta reports an OS_METADATA_WRITABLE-granted override, the read that made the #7637 correction checkable)", + "objectui packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx, 1311-1323 (the artifact-backed heuristic behind the read-only banner; cross-linked to objectui#4308)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: both sides of the registry's runtime-write gates (not_overridable / not_creatable vs accepted overlay / accepted create), variants sampled straight from DEFAULT_METADATA_TYPE_REGISTRY and pinned to the overlay-precedence suite", "ref": "claude/platform-test-checklist-ocwugl" }, @@ -1108,20 +1108,20 @@ "fixtures": { "app": "showcase", "requires": [ - "the stock read-only code package com.example.showcase with its packaged flows (showcase_urgent_task_alert, showcase_task_done_notify_owner, …) listed in the Automations rail — the rail merges published + draft flows package-scoped (objectui StudioDesignSurface.tsx:3286 loadPackageSurfaces)", + "the stock read-only code package com.example.showcase with its packaged flows (showcase_urgent_task_alert, showcase_task_done_notify_owner, …) listed in the Automations rail — the rail merges published + draft flows package-scoped (objectui StudioDesignSurface.tsx loadPackageSurfaces)", "a vendored console matching .objectui-sha — the pillar markup under test is objectui's, so a stale bundle tests yesterday's gate" ], "knownGaps": [ - "CURRENT SHAPE, RECORDED SO RUNS DON'T RE-DERIVE IT (objectui f7c52e2): the AutomationsPillar's readOnly prop is documented as 'hide/disable flow-authoring affordances' (StudioDesignSurface.tsx:3218-3219) and the New-flow button (:3478) and autosave (:3390) consult it — but the enable/disable switch does NOT (disabled={!isEditable || !!saving}, :3442) and the flow node inspector mounts with readOnly hardcoded false (:3583), where the Data pillar threads the real flag into its editors (:2999, :3099). So today the switch is clickable-but-server-refused and inspector edits take locally and silently vanish on reload. The clauses below keep the PROMISE as the assertion and name this shape as the expected finding", + "CURRENT SHAPE, RECORDED SO RUNS DON'T RE-DERIVE IT (objectui f7c52e2): the AutomationsPillar's readOnly prop is documented as 'hide/disable flow-authoring affordances' (StudioDesignSurface.tsx) and the New-flow button and autosave consult it — but the enable/disable switch does NOT (disabled={!isEditable || !!saving}) and the flow node inspector mounts with readOnly hardcoded false, where the Data pillar threads the real flag into its editors. So today the switch is clickable-but-server-refused and inspector edits take locally and silently vanish on reload. The clauses below keep the PROMISE as the assertion and name this shape as the expected finding", "no automated pin exists in either repo for the Automations pillar's read-only posture (checked at objectui f7c52e2: no StudioDesignSurface read-only test covers the switch or the flow inspector) — this item is browser-driven end to end" ] }, "steps": [ "boot the showcase (objectstack dev --ui --seed-admin -p -d file:/tmp//data.db), sign in admin@objectos.ai/admin123; open Studio, switch to the read-only com.example.showcase package, open the Automations pillar", - "pin the precondition: GET /api/v1/packages must report com.example.showcase writable:false — the pillar's readOnly gate is pkgWritable===false (objectui StudioDesignSurface.tsx:502) and an UNKNOWN writability (fetch failed / still loading) deliberately leaves the pillar ungated, so a race here fakes a gate failure (hydration-race)", + "pin the precondition: GET /api/v1/packages must report com.example.showcase writable:false — the pillar's readOnly gate is pkgWritable===false (objectui StudioDesignSurface.tsx) and an UNKNOWN writability (fetch failed / still loading) deliberately leaves the pillar ungated, so a race here fakes a gate failure (hydration-race)", "baseline the server truth: GET /api/v1/meta/flow/showcase_urgent_task_alert (full body) — every later clause diffs against this read", "affordance sweep with the network panel recording: confirm the New-flow button is ABSENT from the rail heading; select the packaged flow and wait past the autosave debounce after touching the canvas — no flow save may leave the browser", - "click the enable/disable switch in the pillar header and capture everything: whether it is disabled/absent, and if clickable, the exact request it fires (today: a flow DRAFT save flipping status active↔obsolete — StudioDesignSurface.tsx:3400-3417), the server's response, and what the UI surfaces (error banner :3524-3528)", + "click the enable/disable switch in the pillar header and capture everything: whether it is disabled/absent, and if clickable, the exact request it fires (today: a flow DRAFT save flipping status active↔obsolete — StudioDesignSurface.tsx), the server's response, and what the UI surfaces (error banner)", "select a flow node and edit a property in the inspector; record whether the edit visibly takes on the canvas/draft, and whether any affordance announces a read-only or will-not-persist state", "reload the pillar (re-open the package) and re-inspect: record whether the inspector edit survived or vanished, and whether its discard was ever announced", "close out: GET /api/v1/meta/flow/showcase_urgent_task_alert again and diff against the baseline — byte-identical" @@ -1130,7 +1130,7 @@ { "clause": "the courtesy gate holds on the affordances that consult it: on a read-only package the New-flow button is absent from the Automations rail and the flow autosave never fires — no draft write leaves the browser from ordinary canvas edits", "oracle": "network", - "verify": "the rail DOM carries no New button (gated on !readOnly, objectui StudioDesignSurface.tsx:3478); the network trace across the edit session and past the autosave debounce carries zero flow save/PUT from the autosave path (blocked on readOnly, :3388-3393)", + "verify": "the rail DOM carries no New button (gated on !readOnly, objectui StudioDesignSurface.tsx); the network trace across the edit session and past the autosave debounce carries zero flow save/PUT from the autosave path (blocked on readOnly)", "evidence": "the rail DOM read + the flow-write-free network trace over the debounce window" }, { @@ -1142,13 +1142,13 @@ { "clause": "the enable/disable switch honors the read-only package lock exactly as the New-flow button does: clicking it produces NO metadata write", "oracle": "network", - "verify": "click the switch: the PASS shape is disabled/absent-or-inert (no request fires). CURRENT RECORDED FINDING (objectui f7c52e2): the switch is clickable — its disabled expression omits readOnly (StudioDesignSurface.tsx:3442) — and toggleEnabled fires a flow draft save flipping deployment status (:3400-3417) that the server refuses 422 writable_package_required, surfaced in the pillar error banner. That clickable-but-refused shape is this clause's expected FAIL with the server gate holding: record it as the standing finding, not a pass and not a new defect; only a 2xx on that write escalates to clause 2's hard FAIL", + "verify": "click the switch: the PASS shape is disabled/absent-or-inert (no request fires). CURRENT RECORDED FINDING (objectui f7c52e2): the switch is clickable — its disabled expression omits readOnly (StudioDesignSurface.tsx) — and toggleEnabled fires a flow draft save flipping deployment status that the server refuses 422 writable_package_required, surfaced in the pillar error banner. That clickable-but-refused shape is this clause's expected FAIL with the server gate holding: record it as the standing finding, not a pass and not a new defect; only a 2xx on that write escalates to clause 2's hard FAIL", "evidence": "the click's network capture (no request — or the refused request + the error-banner screenshot)" }, { "clause": "inspector edits are inert OR their discard is announced: the flow node inspector on a read-only package either disables its inputs or tells the author the edit will not persist — an edit that visibly takes and silently vanishes on reload is the failure", "oracle": "dom", - "verify": "edit a node property, confirm what rendered, reload the pillar and re-read the inspector + canvas: the edit must not silently vanish. CURRENT SOURCE-PREDICTED SHAPE (objectui f7c52e2): the inspector mounts with readOnly hardcoded false (StudioDesignSurface.tsx:3583 — contrast the Data pillar threading the real flag, :2999/:3099) while autosave stays blocked, so the edit takes locally and vanishes on reload unannounced — the predicted verdict is this clause's FAIL; capture it as the finding", + "verify": "edit a node property, confirm what rendered, reload the pillar and re-read the inspector + canvas: the edit must not silently vanish. CURRENT SOURCE-PREDICTED SHAPE (objectui f7c52e2): the inspector mounts with readOnly hardcoded false (StudioDesignSurface.tsx — contrast the Data pillar threading the real flag) while autosave stays blocked, so the edit takes locally and vanishes on reload unannounced — the predicted verdict is this clause's FAIL; capture it as the finding", "evidence": "before-edit / after-edit / after-reload screenshots + the inspector input DOM state (disabled or not)" } ], @@ -1159,14 +1159,14 @@ ], "traps": ["stale-console-bundle", "hydration-race", "automation-input"], "source": [ - "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:502 (readOnly = pkgWritable===false; unknown writability deliberately leaves the pillar ungated), :3218-3219 (the prop's own contract: 'hide/disable flow-authoring affordances'), :3388-3393 (autosave blocked on readOnly), :3436-3450 (the enable/disable switch — disabled={!isEditable || !!saving}, readOnly absent), :3400-3417 (toggleEnabled = client.save flow draft flipping status active↔obsolete), :3478 (New-flow gated on !readOnly), :3583 (inspector readOnly hardcoded false; contrast Data pillar threading :2999/:3099), :3286 (the rail lists package-scoped flows) — objectui owns this pillar's markup, so locator and gate fixes land there (ADR-0054)", - "packages/metadata-protocol/src/sys-metadata-repository.ts:1563 + packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts (the 422 [writable_package_required] refusal a read-only code package answers to metadata writes — the server half of the lock; com.example.showcase is source-loaded read-only)", + "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (readOnly = pkgWritable===false; unknown writability deliberately leaves the pillar ungated), (the prop's own contract: 'hide/disable flow-authoring affordances'), (autosave blocked on readOnly), (the enable/disable switch — disabled={!isEditable || !!saving}, readOnly absent), (toggleEnabled = client.save flow draft flipping status active↔obsolete), (New-flow gated on !readOnly), (inspector readOnly hardcoded false; contrast Data pillar threading), (the rail lists package-scoped flows) — objectui owns this pillar's markup, so locator and gate fixes land there (ADR-0054)", + "packages/metadata-protocol/src/sys-metadata-repository.ts + packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts (the 422 [writable_package_required] refusal a read-only code package answers to metadata writes — the server half of the lock; com.example.showcase is source-loaded read-only)", "docs/adr/0126-packaged-metadata-customization-model.md §2/§3 (flow = Regime C: locked base, in-place edit refused loudly at the write door — card F2)", "#12438 (the ADR-0126 packaged flow/action sweep this item lands from)", "access-security.readonly-package-locks-studio (object designer + permission matrix — cross-referenced, not widened), access-security.packaged-flow-write-door-parity (the API-side door parity for the same packaged flow), automation.setup-packaged-automation-board (the ledgered enable/disable surface — a different mechanism from this pillar's status flip)" ], "history": [ - { "revision": 1, "date": "2026-08-26", "change": "new item (#12438 card F2, UI half): the Studio Automations pillar's read-only package lock — the promise (no in-place edit, switch produces no metadata write, inspector edits inert or announced) asserted with the current source shape recorded as the expected finding: the switch is clickable with only the server-side 422 behind it (disabled expression omits readOnly, StudioDesignSurface.tsx:3442) and the inspector hardcodes readOnly:false (:3583) so edits take locally and silently vanish on reload; New-flow (:3478) and autosave (:3390) honor the gate. Grounded in objectui f7c52e2 + the framework's writable_package_required door; owns the Automations pillar only, cross-referencing readonly-package-locks-studio for the surfaces it already owns", "ref": "#12438" } + { "revision": 1, "date": "2026-08-26", "change": "new item (#12438 card F2, UI half): the Studio Automations pillar's read-only package lock — the promise (no in-place edit, switch produces no metadata write, inspector edits inert or announced) asserted with the current source shape recorded as the expected finding: the switch is clickable with only the server-side 422 behind it (disabled expression omits readOnly, StudioDesignSurface.tsx) and the inspector hardcodes readOnly:false so edits take locally and silently vanish on reload; New-flow and autosave honor the gate. Grounded in objectui f7c52e2 + the framework's writable_package_required door; owns the Automations pillar only, cross-referencing readonly-package-locks-studio for the surfaces it already owns", "ref": "#12438" } ] }, { @@ -1187,9 +1187,9 @@ "a vendored console matching .objectui-sha" ], "knownGaps": [ - "DASHBOARD OVERLAY DOOR VERIFIED PRESENT at authoring time (this worktree): DEFAULT_METADATA_TYPE_REGISTRY declares dashboard allowOrgOverride:true (packages/spec/src/kernel/metadata-plugin.zod.ts:787), same as view (:785) — so the dashboard leg is ASSERTED, not waived. If a run finds the flag flipped, revise this item (ADR-0005 admission-pair change) before scoring", + "DASHBOARD OVERLAY DOOR VERIFIED PRESENT at authoring time (this worktree): DEFAULT_METADATA_TYPE_REGISTRY declares dashboard allowOrgOverride:true (packages/spec/src/kernel/metadata-plugin.zod.ts), same as view — so the dashboard leg is ASSERTED, not waived. If a run finds the flag flipped, revise this item (ADR-0005 admission-pair change) before scoring", "dashboards.system-overview-live-counts drives the SAME system_overview board expecting its stock shape — the dashboard leg here MUST end with the reset-overlay step; an overlay left behind corrupts that item's next run", - "the neighbouring failure class is objectui#4308 mis-tiering (an item in a WRITABLE package shown behind the installed-package lock): if the packaged view/dashboard opens LOCKED, read the ResourceEditPage tiering (isArtifactItem :1144-1148; artifact-backed canWrite = allowOrgOverride, :1592-1599) and the layered envelope's provenance, and forge the raw PUT, before filing a registry defect" + "the neighbouring failure class is objectui#4308 mis-tiering (an item in a WRITABLE package shown behind the installed-package lock): if the packaged view/dashboard opens LOCKED, read the ResourceEditPage tiering (isArtifactItem; artifact-backed canWrite = allowOrgOverride) and the layered envelope's provenance, and forge the raw PUT, before filing a registry defect" ] }, "steps": [ @@ -1235,23 +1235,23 @@ } ], "negative": [ - "a packaged view or dashboard opening behind the installed-package lock is the FAIL of the shipped tier-1 claim (content/docs/capabilities/integrations.mdx:17, content/docs/build-without-code.mdx:37) — but before filing it as a registry defect, forge the raw PUT: if the server ACCEPTS while the UI locks, it is the objectui#4308 client mis-tiering class, filed against objectui, not the registry", + "a packaged view or dashboard opening behind the installed-package lock is the FAIL of the shipped tier-1 claim (content/docs/capabilities/integrations.mdx, content/docs/build-without-code.mdx) — but before filing it as a registry defect, forge the raw PUT: if the server ACCEPTS while the UI locks, it is the objectui#4308 client mis-tiering class, filed against objectui, not the registry", "a reset that answers 200 while the read or render still serves the overlay is a FAIL — the restore must flip the served body", "leaving the system_overview overlay behind after the run corrupts dashboards.system-overview-live-counts' stock fixture — the reset step is part of the item, not cleanup courtesy", "SCOPE: the raw-PUT registry gate in both directions (403 not_overridable on locked kinds, accepted overlay + DELETE reset semantics at API grain) is studio-authoring.org-override-registry-gate — cite it, never duplicate it; studio-authoring.view-authoring-live deliberately avoids packaged views, and this item is its packaged complement" ], "traps": ["stale-console-bundle", "hydration-race", "automation-input", "cache-staleness"], "source": [ - "packages/spec/src/kernel/metadata-plugin.zod.ts:785 (view allowOrgOverride:true), :787 (dashboard allowOrgOverride:true — the overlay door the dashboard leg rides, verified present), :828 (flow allowOrgOverride:false — the same-surface Regime-C negative)", + "packages/spec/src/kernel/metadata-plugin.zod.ts (view allowOrgOverride:true), (dashboard allowOrgOverride:true — the overlay door the dashboard leg rides, verified present), (flow allowOrgOverride:false — the same-surface Regime-C negative)", "docs/adr/0126-packaged-metadata-customization-model.md §3 (Regime O = view, dashboard, report, translation, email_template; flow = Regime C)", - "content/docs/capabilities/integrations.mdx:17 + content/docs/build-without-code.mdx:37 (the E1 tier-1 claim under test: 'views and dashboards are yours to edit directly' on installed/packaged apps)", + "content/docs/capabilities/integrations.mdx + content/docs/build-without-code.mdx (the E1 tier-1 claim under test: 'views and dashboards are yours to edit directly' on installed/packaged apps)", "packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts (the code-shipped built-in board the dashboard leg edits)", - "objectui packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx:1144-1148 (isArtifactItem tiering), :1592-1599 (artifact-backed canWrite = allowOrgOverride — why the view/dashboard open editable and the flow does not), :1190-1191 (resettable → the Reset-overlay verb), :2166-2190 (the installed-package lock banner) — objectui owns the designer and end-user markup, so locator fixes land there (ADR-0054); the framework owns the registry flags and the overlay/reset doors", + "objectui packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx (isArtifactItem tiering), (artifact-backed canWrite = allowOrgOverride — why the view/dashboard open editable and the flow does not), (resettable → the Reset-overlay verb), (the installed-package lock banner) — objectui owns the designer and end-user markup, so locator fixes land there (ADR-0054); the framework owns the registry flags and the overlay/reset doors", "#12438 (the ADR-0126 sweep this item lands from)", "studio-authoring.org-override-registry-gate (the raw-PUT registry-gate half — cross-referenced, not duplicated), studio-authoring.view-authoring-live (the non-packaged authoring loop this item complements), dashboards.system-overview-live-counts (the same board's live-count semantics — and the reason the overlay must be reset)" ], "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" } + { "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 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" } ] }, { @@ -1277,7 +1277,7 @@ }, "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", + "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); ⛔ 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)", @@ -1290,7 +1290,7 @@ { "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)", + "verify": "the recorded body parses against GetMetaDiagnosticsResponseSchema's shape (protocol.zod.ts); 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" }, { @@ -1349,12 +1349,12 @@ "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/rest/src/rest-server.ts (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 (REST-only route, bare body = GetMetaDiagnosticsResponseSchema, client meta.getDiagnostics, #12038)", + "packages/metadata-protocol/src/protocol.ts (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)", + "packages/spec/src/api/protocol.zod.ts (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 (useGlobalDiagnostics: strict error count vs warn-only count, counts/locked/packages from stats, older-server catch degrades to empty-not-fatal) + DirectoryPage.tsx,242-254 (package-scoped sweep drives tile badges; diagnostics link only when total > 0) + console/AppContent.tsx,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": [ diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs index 6945338e23..1a27a009e6 100644 --- a/scripts/check-platform-checklist.mjs +++ b/scripts/check-platform-checklist.mjs @@ -62,7 +62,32 @@ import { maskComments } from './js-comment-mask.mjs'; import { join, basename } from 'node:path'; const ROOT = new URL('..', import.meta.url).pathname; -const AREAS_DIR = join(ROOT, 'docs/qa/platform-checklist/areas'); +const CHECKLIST_DIR = join(ROOT, 'docs/qa/platform-checklist'); +const AREAS_DIR = join(CHECKLIST_DIR, 'areas'); + +/** + * Every authored `.json`/`.md` file in the checklist family, as paths relative + * to `CHECKLIST_DIR`. `runs/` is excluded: run records are outputs, written by + * a runner against whatever the ledger said at the time, and holding a past + * record to today's authoring rules would make the rule unfixable. + * + * @param {string} dir + * @param {string} [prefix] + * @returns {string[]} + */ +function familyFiles(dir, prefix = '') { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (entry.name === 'runs') continue; + out.push(...familyFiles(join(dir, entry.name), rel)); + } else if (entry.name.endsWith('.json') || entry.name.endsWith('.md')) { + out.push(rel); + } + } + return out; +} const STATUSES = new Set(['active', 'draft', 'retired']); const PRIORITIES = new Set(['P0', 'P1', 'P2']); @@ -1114,18 +1139,100 @@ export const NEIGHBOURING_MAP: Readonly> = Object.freeze( return { checked, failures }; } +// ── Source-line citations ─────────────────────────────────────────────────── +// An item's `source` (and the prose beside it) is the evidence pointer a later +// runner uses to decide whether the item still describes reality. This ledger +// used to pin those pointers at `file:line` — and a line number is the ONE part +// of a citation that rots on an edit the citation has nothing to do with: two +// TSDoc blocks widening in the cited file shift every symbol below them, and +// every pinned line silently starts naming something else. Nothing here +// resolved a citation, so the rot was exit-0 by construction: the pointer keeps +// reading as "verified against source" while pointing somewhere else, which is +// strictly worse than no pointer at all. +// +// The whole class was stripped: `file` plus the symbol name is the load-bearing +// half and does not rot in place. This check keeps them from coming back. It is +// deliberately NOT a symbol resolver (that is the follow-up) — it is the cheap +// half, and the cheap half is the one that removes a false signal today. +// +// A citation is a colon-then-digits reached one of two ways, because the ledger +// spelled it both ways: anchored to a source filename (`manifest.zod.ts:158`), +// or BARE, continuing a filename named earlier in the same sentence +// (`ManifestSchema id :140 and version :202`). The bare half is why a plain +// "filename followed by a colon" rule is not enough — and the bare half is the +// worse one, since it carries no file at all, only a number. +// +// The second branch is a negative lookbehind rather than a list of allowed +// prefixes: it is what separates a citation from the neighbours that share the +// colon-then-digit shape, all of which occur in this ledger and must stay +// silent — HTTP status (`status:409`), config literals (`{maxRetries:3}`), +// ports (`http://localhost:3000`), clock times (`08:00`, `...T00:00:00Z`) and +// JSON quoted in prose (`{"scannedTypes":1}`). Each is pinned below. +const SOURCE_LINE_CITATION = + /(?:\.(?:ts|tsx|mts|cts|js|mjs|cjs|json|jsonc|md|mdx|ya?ml|sql|css|html|sh|py|toml)|(? { + checked++; + if (!ok) failures.push(what); + }; + const n = (s) => findSourceLineCitations(s).length; + + // FIRES — the spellings this ledger actually carried. + t('C1 a file-anchored citation is caught', n('packages/spec/src/kernel/manifest.zod.ts:158') === 1); + t('C2 a line RANGE is caught, as one hit not two', n('rest-server.ts:1276-1331') === 1); + t('C3 a bare continuation citation is caught', n('ManifestSchema id :140 and version :202') === 2); + t('C4 a parenthesised bare citation is caught', n('Ada Auditor holds ONLY auditor (:395)') === 1); + t('C5 an approximate `~:` citation is caught', n('computeAuthGate ~:5084-5160') === 1); + t('C6 a comma/slash-chained run is caught in full', n('storage-routes.ts:241-243,:255,:267') === 3); + + // STAYS SILENT — the neighbours that share the colon-then-digit shape. + t('S1 an HTTP status in prose is not a citation', n("thrown {code:'DELETE_RESTRICTED', status:409}") === 0); + t('S2 a config literal is not a citation', n('retry {maxRetries:3, backoffMs:1000}') === 0); + t('S3 a URL port is not a citation', n('probe http://localhost:3000/_console/') === 0); + t('S4 a clock time is not a citation', n('daily 08:00 UTC; today() == 2026-08-31T00:00:00Z') === 0); + t('S5 JSON quoted in prose is not a citation', n('a 200 {"scannedTypes":1,"stats":{}}') === 0); + t('S6 an ADR section reference is not a citation', n('ADR-0025 §3.3 and #13479') === 0); + t('S7 the README placeholder spelling of the ban is not itself a citation', n('never pin `file.ts:NNN` or a bare `:NNN`') === 0); + + return { failures, checked }; +} + if (process.argv.slice(2).includes('--self-test')) { const trap = selfTestTrapVocabulary(); const prov = selfTestProvisioningUse(); const unref = selfTestUnreferencedRecipes(); const metaCall = selfTestMetaCallSpelling(); - const failures = [...trap.failures, ...prov.failures, ...unref.failures, ...metaCall.failures]; + const cites = selfTestSourceLineCitations(); + const failures = [...trap.failures, ...prov.failures, ...unref.failures, ...metaCall.failures, ...cites.failures]; if (failures.length === 0) { console.log( - `✓ check-platform-checklist --self-test: ${trap.checked + prov.checked + unref.checked + metaCall.checked} assertions — the trap-table extractor reads a good table and REFUSES an empty/renamed/reshaped one;` + + `✓ check-platform-checklist --self-test: ${trap.checked + prov.checked + unref.checked + metaCall.checked + cites.checked} assertions — the trap-table extractor reads a good table and REFUSES an empty/renamed/reshaped one;` + ' `fixtures.provisioning.use` resolves both spellings (own-area key and `:`) and fires on all three dangling shapes;' + ' the unreferenced-recipe direction fires on a recipe nobody uses while leaving a cross-area consumer, a retired consumer and a `$`-annotation alone;' + - ' and the `/meta` call-spelling refusal reads its vocabulary out of the live generated contract, fires on every folded spelling a `call` can instruct, and stays silent on the canonical singular, on parameter placeholders, and on the `why`/`expect`/`source`/`requires` prose that narrates the fold.', + ' and the `/meta` call-spelling refusal reads its vocabulary out of the live generated contract, fires on every folded spelling a `call` can instruct, and stays silent on the canonical singular, on parameter placeholders, and on the `why`/`expect`/`source`/`requires` prose that narrates the fold;' + + ' and the source-line-citation refusal fires on every spelling this ledger carried (file-anchored, range, bare continuation, parenthesised, `~:`, comma/slash-chained) while staying silent on HTTP status, config literals, URL ports, clock times, JSON quoted in prose and the README placeholder that documents the ban.', ); process.exit(0); } @@ -1174,6 +1281,19 @@ if (metaCallControl.failures.length) { process.exit(1); } +// And for the source-line-citation refusal. The control matters more here than +// anywhere else in this file: the ledger is CLEAN of line citations now, so +// this check's real output is permanently empty and its green says nothing on +// its own. A detector that silently stopped matching would be indistinguishable +// from the ledger staying clean — which is precisely the exit-0-by-construction +// shape this check was added to end. +const citationControl = selfTestSourceLineCitations(); +if (citationControl.failures.length) { + console.error('check-platform-checklist: the source-line-citation refusal\'s own positive control FAILED — a rotting `file:line` pointer would pass unreported, and because the ledger is clean nothing else here would ever notice.\n'); + for (const f of citationControl.failures) console.error(` ✗ ${f}`); + process.exit(1); +} + // The folded-spelling vocabulary, read from the contract before anything is // judged against it. Refused rather than defaulted: with no vocabulary every // `call` validates against an empty set and this gate prints the same green it @@ -1512,6 +1632,22 @@ if (!existsSync(COVERAGE_FILE)) { } } +// The source-line-citation sweep, over the whole family rather than the area +// files alone: the same rot lives in README/RUNNER/SWEEP/FOLLOW-UPS prose, and +// FOLLOW-UPS in particular carried more citations than most area files. +let citationsScanned = 0; +for (const rel of familyFiles(CHECKLIST_DIR)) { + const hits = findSourceLineCitations(readFileSync(join(CHECKLIST_DIR, rel), 'utf8')); + citationsScanned++; + for (const hit of hits) { + err( + rel, + null, + `SOURCE LINE CITATION — \`${hit}\`. Line numbers rot on the next unrelated edit to the cited file and nothing can tell a stale one from a fresh one, so the pointer keeps reading as "verified against source" while naming something else. Cite the FILE plus the SYMBOL instead (README.md → "Every call cites framework source as \`file\` plus the symbol it lands in").`, + ); + } +} + if (errors.length) { console.error(`check-platform-checklist: ${errors.length} problem(s)\n`); for (const e of errors) console.error(` ✗ ${e}`); @@ -1531,5 +1667,6 @@ console.log( ` traps: ${TRAPS.size} documented, ${usedTraps.size} in use;` + ` provisioning: ${recipeTotal} area recipes, ${recipeRefs} item references resolved (${qualifiedRefs} area-qualified), ${recipesReferenced}/${recipeTotal} recipes referenced;` + ` meta-URL spelling: ${metaCallsScanned} \`call\` strings scanned against ${FOLDED_META_SPELLINGS.size} folded spellings;` + - ` (self-checks: ${trapControl.checked} trap-vocabulary + ${provisioningControl.checked} provisioning-resolve + ${unreferencedControl.checked} unreferenced-recipe + ${metaCallControl.checked} meta-call-spelling assertions).`, + ` source citations: ${citationsScanned} family files carry no \`file:line\` pin;` + + ` (self-checks: ${trapControl.checked} trap-vocabulary + ${provisioningControl.checked} provisioning-resolve + ${unreferencedControl.checked} unreferenced-recipe + ${metaCallControl.checked} meta-call-spelling + ${citationControl.checked} source-line-citation assertions).`, );