diff --git a/.claude/skills/checklist-run/SKILL.md b/.claude/skills/checklist-run/SKILL.md new file mode 100644 index 0000000000..73ecaa0e41 --- /dev/null +++ b/.claude/skills/checklist-run/SKILL.md @@ -0,0 +1,114 @@ +--- +name: checklist-run +description: > + Execute the platform test checklist (docs/qa/platform-checklist/) against a real + running app and produce a run record. Use whenever the maintainer says "测一下 + <功能>", "跑这个测试项", "run the checklist for ", "test this feature + file", "验证 <功能点>", or points at a framework source file and asks whether it + still works. Takes a SELECTOR (item id · area · capability kind · priority · a + release · or a source-file path) and drives every matched item through its steps + following RUNNER.md. The companion to `coverage-sweep` (which AUTHORS items); this + one RUNS them. NOT a customer-published skill — internal agent tooling (lives in + .claude/, never in the published `skills/` dir). +metadata: + # Hides this skill from interactive `npx skills add objectstack-ai/objectstack` + # discovery — every SKILL.md outside `skills/` must carry this marker + # (template-consistency.test.ts enforces it). + internal: true +--- + +# Checklist run — execute selected items against a live app + +You resolve a **selector** to a set of checklist items, boot the app in isolation, drive +each item's steps in the browser / over the API, and emit a **run record**. The method +for judging each clause (verdicts, oracle hierarchy, evidence, the anti-false-positive +self-check) is **`docs/qa/platform-checklist/RUNNER.md`** — read it first and obey it; +this skill is the trigger, the selection contract, and the isolation/parallelism plan, +not a second copy of the runner protocol. + +Environment know-how (boot, the dist build model, the vendored-console trap, browser +escape hatches) is the **`dogfood-verification`** skill — read it too. You are not +reinventing how to boot; you are executing a specific list against a boot. + +## 0. Resolve the selector — deterministic, no guessing + +Never eyeball which items to run. Ask the resolver: + +``` +node scripts/checklist-select.mjs --json +``` + +Selectors (one per run): + +| selector | runs | +|---|---| +| `platform-core.console-login` (bare id) | that one item | +| `area:records-forms` (or bare `records-forms`) | every item in the area | +| `capability:hook` | items mapped to a metadata kind in `coverage.json` | +| `priority:P0` | the standing smoke | +| `surface:api` | every API-surface item (cheap — no browser build needed) | +| `since:v17` | everything introduced in a release (the release-sweep filter) | +| **`file:packages/plugins/plugin-approvals/src/approval-service.ts`** | **items whose `source[]` cites that file — "test whatever covers this file"** | +| `all` | the whole checklist | + +`--json` gives the runnable list (id · priority · surface · revision). **Blocked items are +excluded by default** — they can't run on stock fixtures; pass `--include-blocked` only to +record them as `blocked` with their fixture reason. **Pin the `revision`** the resolver +reports into the run record: a verdict is only valid for the revision it ran against. + +## 1. Plan the run by surface — build only what you need + +Read the matched items' `surface`: + +- **All `api` / `build` / `cli`** → no console build. Boot the framework (`objectstack dev`) + and drive REST/CLI. Fast (~minutes). +- **Any `browser` / `mixed`** → you need the vendored console dist. It builds SEPARATELY + from the showcase workspace closure (`pnpm objectui:build` from the pinned `.objectui-sha`); + the first boot 404s `/_console/` until it exists (dogfood §2 — a real precondition, record + it, don't fake a block). Budget the build (~10–30 min on a cold monorepo); it dominates + wall time, the browser driving is minutes. + +Build once, up front, for the whole run. + +## 2. Isolate, then execute (per dogfood §0) + +- Own free non-default port + own file DB **per concurrently-running item** + (`--seed-admin -d file:/tmp//.db`). Two runs sharing a port/DB/browser tab is + the `shared-browser-tab` trap. +- **Parallelism:** fan API-surface items out in parallel (each its own port, cheap). Run + browser items **few-at-a-time** (2–3), each its own port + browser context — a single + machine's CPU and one shared display contend past that. When dispatching runner + subagents, **they must be `opus`**, each given: the item JSON, RUNNER.md, the + dogfood skill, its own port/DB, and the results-out-of-repo rule (§4). +- Execute each item's `steps` faithfully; judge each `acceptance` clause and each + `negative` against its declared `oracle`, capturing the `evidence` the clause names. + **Server truth outranks pixels; DOM only after a screenshot confirms render; a `fail` + needs reproduction ×2 + the automation self-check + a filed issue** (RUNNER §rules). + +## 3. When the run teaches you something about the ITEM + +A run that discovers the item's `steps` are wrong (a moved route, a renamed key, an +expiry path that needs localStorage cleared too) is the checklist working. That is a +checklist EDIT — do it in a **worktree** (PD#11): revise the item, bump `revision`, append +a `history` entry, keep `node scripts/check-platform-checklist.mjs` green, and land it on a +task branch. Product defects found while running go to `FOLLOW-UPS.md` (or a filed issue) +as expected-fail probes — never tick a clause green over a real defect. + +## 4. The run record — results do NOT go in the repo + +Write one JSON per run in the shape RUNNER.md defines (env with framework sha + +`.objectui-sha` + port + db; per-clause verdicts each naming its evidence; derived item +verdict; issues). **`runs/` is git-ignored** — the record and its screenshots stay in the +executing environment / the tracking issue / an external QA store, never committed. The +committed source is the checklist under `areas/`; a run is a dated assertion about one +build and belongs with that build's artifacts. Report the per-clause verdict table + the +evidence paths + the env-setup-vs-test time split back to the maintainer. + +## Guardrails + +- **Don't fake coverage.** Missing fixture → `blocked(fixture)` with the reason; unbuilt + console → build it or record `blocked(environment)`; a half-proven item is `partial`, + not `pass`. A blocked verdict WITH evidence is a successful run; a faked pass is not. +- **Don't run blocked items as if runnable** — the resolver hides them for this reason. +- **One selector, one run record.** For a release sweep, run `since:vN` and `priority:P0` + as separate records rather than smearing them together. diff --git a/.claude/skills/coverage-sweep/SKILL.md b/.claude/skills/coverage-sweep/SKILL.md new file mode 100644 index 0000000000..9fef2e080b --- /dev/null +++ b/.claude/skills/coverage-sweep/SKILL.md @@ -0,0 +1,61 @@ +--- +name: coverage-sweep +description: > + Re-audit the platform test checklist (docs/qa/platform-checklist/) for coverage + gaps and author the missing items — the five-angle capability sweep. Use whenever + the maintainer says "跑一轮 coverage sweep", "run a coverage sweep", "排查测试清单 + 遗漏", "审计测试覆盖", or asks whether some platform surface "有测试吗" and the + answer needs verifying rather than recalling. Also the right tool after a large + platform surface lands or before a major release. NOT a customer-published skill — + this is internal agent tooling (lives in .claude/, never in the published + `skills/` dir). +metadata: + # Hides this skill from interactive `npx skills add objectstack-ai/objectstack` + # discovery — every SKILL.md outside `skills/` must carry this marker + # (template-consistency.test.ts enforces it). + internal: true +--- + +# Coverage sweep — keep the platform test checklist honest + +The canonical method lives in **`docs/qa/platform-checklist/SWEEP.md`** — read it +first and follow it; this skill is the trigger and the orchestration contract, not a +second copy of the procedure. + +## What you are producing + +A delta on `docs/qa/platform-checklist/`: new/extended items in `areas/*.json`, a +reconciled `coverage.json`, defects/docs-drift appended to `FOLLOW-UPS.md` — all +validating green under `node scripts/check-platform-checklist.mjs`, landed on a task +branch per AGENTS.md (worktree-first, PD#11). + +## Orchestration contract + +1. **Worktree first** (PD#11): `git worktree add ../objectstack- -b main`. + All edits there. Read the checklist's current state before dispatching anything. +2. **Five READ-ONLY gap hunters in parallel** — one per SWEEP.md angle (console UI / + spec enums / routes & runtime / built-in apps / docs claims). Each gets: the current + item-id list, the already-known waivers and blocked items (don't re-report), and the + output contract `surface | evidence path | coverage verdict | proposed id | sketch | + fixture?`. Hunters write NO files. +3. **Dedupe into a scratch register** (delete it before landing). Cross-angle + duplicates are high-priority signal, not noise. +4. **Per-area writer agents** — one agent per `areas/*.json` file so writers never + collide; nobody but the orchestrator touches `coverage.json` or `scripts/`. + Every item follows README.md's deep-test contract; missing fixtures become + `blocked`/`knownGaps`, never faked coverage. Writers ground every endpoint, enum, + and error code in source before asserting — treat this skill's own briefs as + hypotheses, source as truth. +5. **Reconcile centrally**: un-waive any kind a hunter proved has a stock fixture + (four of six waivers were stale in the 2026-08 sweep — re-audit every waiver every + time), map new items in `coverage.json`, pin `enumSource` on any new variants + matrix (see README "Variants stay fresh automatically"). +6. **Validate + land**: validator green, then commit on the task branch. Product + defects and docs-drift go to `FOLLOW-UPS.md`; security-sensitive findings are + NEVER filed publicly without the maintainer's decision. + +## Scale guidance + +A full sweep is ~5 hunter + ~8 writer agents. For a scoped question ("X 有测试吗?"), +run ONE hunter on the relevant angle, verify against the checklist, and author only +what's missing — same contract, smaller fleet. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 701ccca281..59e3d0b825 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -193,6 +193,14 @@ jobs: - name: ADR anchors + number uniqueness (governed code names its decision) run: pnpm check:adr-anchors + # NOTE: the standing platform test checklist (docs/qa/platform-checklist/) + # is validated by `pnpm check:platform-checklist`, but by MAINTAINER + # DECISION it is NOT wired into CI — it runs on a periodic manual cadence + # (before a release / after a large platform surface lands), not on every + # PR. The checklist is a QA ledger, not a code gate; keeping it out of the + # per-PR path means an unrelated PR is never blocked by checklist drift. + # Run it by hand: `pnpm check:platform-checklist`. See that dir's README. + # #3280/#3290 org-identifier guard: `organizationId` is the blessed # developer-facing name for the caller's active org in hook/action bodies; # the `session.tenantId` alias was REMOVED in v11 (#3290). Keeps our own diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md new file mode 100644 index 0000000000..9bab64cdd2 --- /dev/null +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -0,0 +1,69 @@ +# Follow-ups — open items from the capability-coverage sweep (2026-08-08) + +Decision register from the capability-coverage sweep. The gap items found by the +five-angle sweep have all been authored into `areas/*.json` (checklist grew 84 → 170 +items; the `api`/`datasource`/`mapping` coverage waivers were corrected). What remains +here is what the sweep surfaced that is **not** a checklist item: product defects to +decide on, and docs that promise retired capabilities. + +## 1. Product defects found during the sweep (decide handling) + +These are real runtime/UI defects the gap hunters hit while grounding items. Each is +captured inside the relevant checklist item as an **expected-fail probe** (so a run +records the actual behavior instead of ticking green), but they are defects, not test +gaps. Security-sensitive ones were deliberately NOT filed publicly — your call. + +| # | defect | evidence | captured in | sensitivity | +|---|---|---|---|---| +| D1 | **Saved-report schedule routes lack an owner check** — `report-service.ts` `unscheduleReport`/`listSchedules` ignore `_context`, so user B can delete user A's report schedule (cross-owner destructive access). The read/run/delete routes ARE owner-gated (deny-as-404); only the schedule routes leak. | packages/plugins/plugin-reports/src/report-service.ts (unscheduleReport/listSchedules) | dashboards.saved-report-ownership (known-gap probe clause) | **SECURITY — not filed publicly; awaiting your decision** | +| D2 | **AppManagementPage enable/disable/set-default/delete are client-only stubs** — the handlers call `toast.success()` with a `TODO: Replace with real API call` and issue no request; an admin sees "success" while nothing changes. | objectui apps/console/src/pages/system/AppManagementPage.tsx | platform-core.app-management-toggle (expected-fail probe) | UX-integrity — safe to file | +| D3 | **`useGlobalUndo.executeOp` issues a bare `ds.update` with no `ifMatch`** — record undo can silently clobber a concurrent edit (no OCC guard on the undo path). | objectui react/src/hooks/useGlobalUndo.ts | records-forms.record-edit-undo (observe-and-flag clause) | correctness — safe to file | +| D4 | **`SharedViewLink` builds dead `/share//?token=` URLs** — client-generated token, no matching console route (only `/s/:token`), no server persistence. Registered but unused. | objectui plugin-view/src/SharedViewLink.tsx | — (not an item; demo-grade) | low — file a cleanup issue | +| D5 | **List "Share" button is a no-op** — renders when `schema.sharing` is set but has no onClick. | objectui plugin-list/src/ListView.tsx | — | low — file a cleanup issue | +| D6 | **`/api/v1/datasources` admin CRUD has no route ledger** — mounted by serve.ts, absent from rest-route-ledger.ts (tranche-3 discipline gap). | packages/services/service-datasource/src/admin-routes.ts | integration-system.datasource-admin-lifecycle (source note) | low — internal discipline | +| D7 | **Parent-only PATCH does not revalidate a stale dependent child** — `evaluateOptionVisibility` skips fields absent from the payload, so changing only the parent leaves a now-invalid child value in place server-side; integrity rests entirely on the client clear. | packages/objectql/src/validation/rule-validator.ts (`!(name in data) continue`) | records-forms.cascading-multilevel-and-clear (knownGap) | integrity — safe to file | +| D8 | **Lookup cascade scope is existence-only server-side** — `assertReferencesResolve` accepts any EXISTING id regardless of `lookupFilters` scope (a cross-account contact that exists is accepted on direct POST). May be by-design (filters = UI courtesy) — needs a maintainer ruling: declared ≠ enforced, or documented courtesy. | packages/objectql/src/engine.ts (assertReferencesResolve) | records-forms.cascading-multilevel-and-clear (knownGap) | integrity/design — needs ruling | + +## 2. Docs promise capabilities the runtime doesn't deliver (PD#10, docs side — file docs issues) + +The capability docs advertise features that were retired or never shipped. Under Prime +Directive #10 ("never advertise a capability the runtime doesn't deliver") these are +docs bugs, not checklist items. + +- **Recycle bin / soft delete** — promised in `content/docs/capabilities/{data,integrations}.mdx`, + but `enable.trash` was RETIRED ("every delete has always been a hard delete; soft delete + parked at #3146", object.zod.ts retired-key guidance). → fix docs or ship the feature. +- **Recently-visited lists** — `enable.mru` retired/never implemented. The console DOES ship a + recents rail (UnifiedSidebar) — reconcile whether the doc claim maps to that surface or a dead one. +- **TV display pages / discussion threads** — promised (analytics.mdx, build-without-code.mdx). + Discussion = the real chatter surface (now covered by records-forms.record-discussion-mentions); + display pages have no spec surface found → confirm removal or file. +- **Five data-depth scopes** (permissions.mdx) — `own_and_reports`/`unit`/`unit_and_below` are + ENTERPRISE (hierarchy-security). The open checklist correctly drives own/org only. Optional both-sides + probe: authoring an intermediate depth in the open edition must degrade LOUDLY (ADR-0049), not + silently to `own` — could become a checklist item if you want it. + +## 3. Fixtures worth adding (would un-block currently-blocked items) + +The 8 `blocked` items are blocked on missing stock fixtures, not on the platform. Adding +these to the showcase would make them runnable: + +- a `publicSharing.enabled` object → unblocks `access-security.share-link-capability-tokens`. +- one configured OIDC/social IdP → unblocks `identity-auth.oauth-app-consent-loop`, + `linked-accounts-social`, and the existing `sso-enforced-first-paint`. +- a gantt view with `dependenciesField` + `lockField` + `parentField` → unblocks the + fixture-gated variants of `records-forms.gantt-interactions`. +- a not-auto-bound audience suggestion → unblocks the confirm/dismiss half of + `access-security.suggested-binding-loop`. +- the `IMPORT_CONSOLE_LIVE` import-harness backend → unblocks `records-forms.import-job-undo-cancel`. +- an approval-escalation clock-control/`runEscalations()` harness → unblocks `approvals.sla-escalation`. +- a second signed-up (non-admin) user in seeds, or a documented sign-up step in the runner → + removes the recurring "needs a 2nd user" knownGap on several persona-gated items. + +## 4. Notes + +- `PENDING-GAPS.md` (the full deduped gap register that drove the authoring) can be deleted + once you've reviewed §1–§2 above — it was scaffolding; this file is the durable residue. +- The checklist itself (`areas/*.json`, `coverage.json`, `README.md`, `RUNNER.md`, + `scripts/check-platform-checklist.mjs`) ships in this branch; this file carries the + decisions that remain with the maintainer. diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md new file mode 100644 index 0000000000..e281bd0c15 --- /dev/null +++ b/docs/qa/platform-checklist/README.md @@ -0,0 +1,232 @@ +# Platform test checklist — standing ledger + +A durable, machine-readable checklist of platform capabilities that an **AI agent +executes** against a running app (browser + API + CLI + build gates). It replaces the +one-off shapes release verification used before — a hand-written table per release +([docs/plans/release-15.1-test-plan.md](../../plans/release-15.1-test-plan.md)) and a +checkbox issue per release (#3358) — with one ledger that **accumulates across +releases**, supports append/change without losing history, and pins every tick to an +acceptance oracle and captured evidence. + +Validated by `pnpm check:platform-checklist` (`scripts/check-platform-checklist.mjs`) — +a zero-dependency structural + coverage check. **By maintainer decision it runs on a +periodic MANUAL cadence, not in CI**: run it before a release, after a large platform +surface lands, or alongside a `coverage-sweep` / `checklist-run`. It is a QA ledger, not +a per-PR code gate, so an unrelated PR is never blocked by checklist drift. Execution +protocol for agents: [RUNNER.md](./RUNNER.md). Run records: [runs/](./runs/README.md). + +**Two internal skills drive this ledger** (`.claude/skills/`, never published): +`coverage-sweep` **authors** items (find gaps → write them, per +[SWEEP.md](./SWEEP.md)); `checklist-run` **executes** them (pick items by selector → +drive them → emit a run record, per [RUNNER.md](./RUNNER.md)). The runner resolves what +to test with `scripts/checklist-select.mjs ` — an item id, an `area:`, a +`capability:`, a `priority:`, a `since:vN` release, or a **`file:`** that maps a +framework source file to the items whose `source` cites it ("test whatever covers this +file"). + +## Layout + +``` +docs/qa/platform-checklist/ + README.md ← this file: what an item is, how to append / change / retire + RUNNER.md ← how an AI runs the checklist accurately (verdicts, oracles, evidence) + areas/*.json ← the ledger, sharded by feature area (append here) + coverage.json ← capability-coverage ratchet: every governed metadata kind → items or waiver + runs/ ← run-record FORMAT contract only; results are git-ignored, never committed +``` + +Sharding by area keeps parallel edits conflict-free: two agents appending to different +areas never touch the same file, and slug ids (below) never collide the way +next-sequential numbers do. + +## Item anatomy + +```jsonc +{ + "id": "approvals.per-group-signoff", // "." — immutable, globally unique, never reused + "title": "Per-group sign-off (会签) needs one approval from EACH group", + "since": "v16", // release that introduced the capability + "status": "active", // active | draft | retired + "revision": 1, // bumps on any semantic edit + "priority": "P1", // P0 = release-gating smoke · P1 = core · P2 = extended + "surface": "browser", // browser | api | cli | build | mixed (the 15.1 plan's 🖥/🔌 lanes) + "personas": ["…"], // who the runner signs in as + "fixtures": { // what the environment must provide — the #1 cause of + "app": "showcase", // blocked runs in #3358 was missing fixtures, so they are + "requires": ["…"], // declared up front, and known gaps are recorded, not + "knownGaps": ["…"] // rediscovered every sweep + }, + "steps": ["…"], // how to exercise it + "acceptance": [ // ★ the acceptance criteria — one clause per assertable fact + { "clause": "what must hold", + "oracle": "api", // api | network | screenshot | dom | log | test | build + "verify": "how to consult the oracle, concretely", + "evidence": "what artifact the run must capture" } + ], + "negative": ["…"], // the other side of every gate (deny/absence cases) + "variants": ["…"], // enumerable-surface matrix (field types, chart types, flow + // nodes, operators…) — derived from the spec's own Zod enums, + // source cited; one clause requires per-variant verification + "traps": ["hydration-race"], // known false-positive risks (vocabulary in RUNNER.md) + "automated": { "kind": "e2e", "ref": "path/to/pinning.test.ts" }, // set when a permanent test pins it + "blocked": { "by": "fixture", "ref": "#NNNN" }, // standing blocker, waive-with-a-reference + "source": ["#3358 §1"], // where the expectation comes from + "history": [ { "revision": 1, "date": "…", "change": "…", "ref": "#PR" } ] +} +``` + +Design notes: + +- **Acceptance is clause-grained**, because runs are clause-grained: the #3358 sweeps + repeatedly proved half an item and honestly left the box unchecked ("upload guard — + not ticking on the strength of a label"). Clause verdicts let a run record *which* + half passed instead of collapsing to one checkbox. +- **Every clause names its oracle.** The oracle hierarchy and the anti-false-positive + rules live in [RUNNER.md](./RUNNER.md); the validator only enforces that an oracle is + declared — an oracle-free clause is an invitation to tick on vibes. +- **`automated` is the 🤖 lane** of the 15.1 plan: once a permanent test pins an item, + runs may satisfy it by executing that test and citing its output as evidence, instead + of re-driving the browser. + +## Lifecycle — append, change, retire (never delete) + +- **Append** — add an item to its area file (or add a new area file). Pick an + `.` id that will still make sense in two years; ids are immutable and + never reused. New-capability items land with `since: v` in the same PR as + the capability, or from the release notes at release time. +- **Change** — edit the fields, bump `revision`, append a `history` entry saying what + changed and why. The revision matters because run records pin the revision they ran + against: a semantic edit silently re-validating old results is exactly what the + validator's revision/history check exists to stop. +- **Retire** — set `status: "retired"` + `retiredReason` (and `supersededBy` when a + successor exists). The row stays in the file; deleting rows destroys the history that + makes old run records interpretable. Retire when the capability is removed + (ADR-0049 enforce-or-remove) or the item is folded into a successor. +- **Blocked is not a lifecycle state** — it's a standing annotation (`blocked: {by, + ref}`) meaning "not runnable on stock fixtures today, tracked at ". The + showcase-side fixture gaps #3358 uncovered (#3408, #3409, #3415) each cost a sweep to + rediscover; recording the gap on the item is what stops that. + +## Capability coverage — every capability the platform has gets tested + +`coverage.json` makes "凡是有的能力, 都要测试" mechanical instead of aspirational. The +universe of capabilities is **derived, not hand-kept**: every metadata kind with a +`packages/spec/liveness/.json` ledger (the ADR-0049 governed set) must be mapped +to at least one checklist item, or waived with a written reason. The validator +(`pnpm check:platform-checklist`, run on the manual cadence below — **not** wired into +CI) reports both directions — an unmapped kind is flagged (the platform grew a +capability the checklist doesn't test), and a mapped kind whose liveness ledger +disappeared is flagged too (the entry outlived the capability). This is the +`examples/app-showcase/src/coverage.ts` demonstrated-or-waived ratchet, applied to +testing instead of demonstration. + +Enumerable surfaces *inside* a capability (49 field types, 20 chart types, flow node +types, query operators, decision actions, …) are covered by `variants` matrices on the +items themselves, each derived from the spec's own Zod enums with the source cited. + +**Variants stay fresh automatically.** A matrix item may pin the spec enum it was +authored against with an `enumSource` field: + +```jsonc +"enumSource": { + "file": "packages/spec/src/data/field.zod.ts", // repo-root-relative spec source + "export": "FieldType", // the exported z.enum(...) const + "expect": 49 // member count the variants match +} +``` + +The validator extracts the enum's *current* member count from that source (comment- +stripped, deduped) and fails when it no longer equals `expect`. So when the platform +grows a 50th field type or a 21st chart type, the next `check:platform-checklist` run +goes red with a precise instruction: revise the variants matrix, bump the item revision, +set `expect` to the new count. This closes the gap the coverage ratchet alone left — the +kind-level ratchet catches a *new metadata kind*, `enumSource` catches a *new value in +an existing kind's enum* — so "spec grew a variant" is caught by the manual check +instead of drifting silently (the showcase `coverage.test.ts` only catches it +indirectly). Items currently pinned: field types, chart types, action locations, webhook +triggers, flow node types. Pin more as matrices are added. + +A waiver is a debt marker, not an exemption: it names what fixture or surface is +missing, so paying it down is a matter of adding the fixture and flipping the entry to +`items`. + +### Variants freshness — spec enum drift is caught on the item itself + +Matrix items may pin the spec enum their `variants` were authored against: + +```jsonc +"enumSource": { "file": "packages/spec/src/data/field.zod.ts", "export": "FieldType", "expect": 49 } +``` + +The validator extracts the enum's CURRENT member count from the spec source at check +time (comment-stripped, deduped) and fails with `VARIANTS STALE` when it no longer +equals `expect` — so the next `check:platform-checklist` run after a 50th field type +lands flags the matrix as stale (revise it, or consciously bump `expect` with a +revision). This closes the loop the kind-level ratchet leaves open: new *kinds* are +caught by the liveness-derived universe, new *members of an existing kind* by these pins. +Enums declared inline (anonymous `z.enum` inside an object literal) cannot be pinned by +export name — those matrices still rely on the showcase `coverage.test.ts` +demonstrability gate. + +### Operating cadence — when to run this (it is NOT in CI) + +By maintainer decision `check:platform-checklist` is **not** wired into per-PR CI: the +checklist is a QA ledger, not a code gate, so an unrelated PR is never blocked by +checklist drift. It runs on a **manual / periodic cadence** instead. Run +`pnpm check:platform-checklist` (zero-dependency, ~1s, no tokens): + +- **before a release** — part of the release sweep below; +- **after a large platform surface lands** — a new metadata kind, a new enum, a new area; +- **whenever you touch the checklist** — the structural + coverage check catches a + dangling id or a forgotten `revision` bump in your own edit; +- **alongside a `coverage-sweep`** (find gaps) **or `checklist-run`** (execute items). + +The trade-off of staying out of CI: a new capability kind or enum value that lands on +`main` between runs is caught at the **next** manual run, not the moment it merged. The +ratchets still detect it — they just aren't a blocking gate. If drift-catching latency +ever matters more than PR independence, re-adding the one-line CI step +(`run: pnpm check:platform-checklist`) restores the automatic posture. + +### How the checklist keeps itself current + +1. **New capability kind** → a `packages/spec/liveness/.json` ledger appears → + the coverage ratchet flags it the next time `check:platform-checklist` runs. +2. **New member of an enumerable surface** → the `enumSource` pin flags the matrix as + stale the next time the check runs (for pinned enums). +3. **New feature inside an existing kind** → process: the feature PR lands a `since: + v` item (same discipline as changesets); the release sweep filter catches + stragglers. +4. **Periodic re-sweep** → [SWEEP.md](./SWEEP.md) is a runbook any AI session can + execute on request ("run a coverage sweep") — five independent gap-hunt angles, + dedupe, author, validate. The 2026-08 sweep it encodes found 3 stale waivers and + ~55 missing items; re-running it is how drift that slips past 1–3 gets caught. + +## How a release sweep works + +A release no longer gets a hand-written checklist. The sweep for `vN` is a **filter +over this ledger**: `since == vN` (the new capabilities) ∪ all `P0` (the standing +smoke) ∪ any item whose `source` cites a PR in the release. The tracking issue for the +sweep links here and hosts discussion; results live as a run record kept OUT of the +repo (in the CI artifact / tracking issue / QA store — `runs/` is git-ignored), plus +findings filed as issues, one per failure. Item text, fixtures learned, and new +traps discovered flow **back into the ledger** as revisions — that is the accumulation +the one-off checklists never had. + +## Relationship to what already exists + +| System | Relationship | +|---|---| +| `.claude/skills/dogfood-verification` | **How** to boot/drive/verify without lying to yourself. RUNNER.md builds on it; the skill is not restated here. | +| `packages/verify` (`objectstack verify`) | Headless auto-derived proof engine (CRUD fidelity, RLS). Items delegate to it via `automated`/`oracle: "test"` rather than re-proving by hand. | +| `packages/qa/dogfood` golden tests | Permanent pins for historical regressions — the `automated.ref` target for API-lane items. | +| `examples/app-showcase/src/coverage.ts` | The ratchet that every spec variant is *demonstrable*. This ledger asserts the demonstrations *work when driven*. Fixture gaps found here should often be fixed there. | +| objectui `e2e/live/*` + ADR-0054 | The browser-lane automation and the UI-testability contract (stable locators, machine-readable async state) that makes browser oracles trustworthy. | +| `docs/plans/release-15.1-test-plan.md`, #3358 | The predecessors this generalizes. Their vocabulary (方式 lanes, 验证要点, 来源) maps to `surface`, `acceptance`, `source`. | + +**Deliberately not reused:** `packages/spec/src/qa/testing.zod.ts` +(`TestScenarioSchema`). Its action vocabulary is headless-API-only +(`create_record`/`api_call`/…) and cannot express browser clauses, visual oracles, +fixtures, or evidence requirements — and it currently has no runtime consumer (a +declared-but-inert surface under ADR-0049/0078, `qa` has no liveness ledger entry). +Adopting it here would have silently changed its meaning; if it gains a real executor +some day, `oracle: "test"` items can point at scenarios expressed in it. diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md new file mode 100644 index 0000000000..7d07f88fb8 --- /dev/null +++ b/docs/qa/platform-checklist/RUNNER.md @@ -0,0 +1,129 @@ +# Runner protocol — executing the checklist accurately + +How an AI agent runs [the platform checklist](./README.md) so that its verdicts can be +trusted. Every rule here was paid for: the #3358 sweeps produced three showcase-defect +discoveries, two real regressions — and also one self-inflicted false alarm and several +"ticked on a label" temptations. The protocol turns those lessons into mechanics. + +Prerequisite reading: the **dogfood-verification** skill +(`.claude/skills/dogfood-verification/SKILL.md`) — environment isolation (§0), the +build/runtime model incl. the vendored-console staleness trap (§2), and the +anti-false-positive rule (§3). This file assumes it and adds the checklist-specific +contract. + +## Verdicts + +Per **clause** (each acceptance entry gets exactly one): + +| verdict | meaning | +|---|---| +| `pass` | oracle consulted, expectation held, evidence captured | +| `fail` | oracle consulted, expectation violated, evidence captured, issue filed | +| `blocked` | could not consult the oracle — carries `{by: fixture\|environment\|dependency\|product-bug, ref}` | +| `skipped` | deliberately not attempted this run (out of scope) | + +Per **item**, derived — never hand-assigned: + +- `pass` — every clause passed; +- `partial` — some passed, none failed (the "proved half, left it unticked" state from + #3358, now first-class instead of a prose apology); +- `fail` — any clause failed; +- `blocked` / `not-run` — nothing consulted. + +**No verdict without evidence.** A clause with no captured artifact is `not-run`, not +`pass`. Evidence means: the API/network trace, the screenshot, the log excerpt, or the +test-run output the clause's `evidence` field names. + +## The accuracy rules + +1. **Oracle hierarchy** — server truth (`api`, `network`, `build`, `test`) outranks + `screenshot`, which outranks `dom`. A `dom` oracle may only be consulted **after** a + screenshot (or equivalent) confirms the surface rendered — post-navigation DOM dumps + return transitional emptiness and are the #1 source of fake "P0: feature missing" + findings (dogfood skill §3). +2. **`fail` is expensive, on purpose.** Before recording one: + - reproduce it **twice**, on fresh loads; + - run the *automation self-check*: could your own driving have caused this? + Coordinate-based clicks, React controlled-input fills, and shared browser tabs + have each produced convincing fake bugs (#3358 had to retract a "dead approve + button" that was a coordinate-click artifact — a ref-targeted click worked); + - check the `traps` field and rule each listed trap out; + - for console UI failures, confirm against current objectui source or a fresh build + — the vendored `/_console` bundle may be stale (skill §2); + - then file the issue and cite it in the run record. A `fail` without a filed issue + is not a completed verdict. +3. **Classify blockers honestly.** Missing seed/persona/fixture → `blocked(fixture)`, + and *record the gap on the item* (`fixtures.knownGaps` or `blocked`) so the next + sweep doesn't rediscover it. A defect in the fixture itself (seed silently failing, + as in #3408/#3415) is a **`fail` against the seed**, not a block — "nothing reports + this" was the actual bug. +4. **Both sides of every gate.** For any permission/visibility/feature gate, verify + presence for the entitled persona AND absence (or server-side rejection) for the + unentitled one. UI absence alone is a client courtesy; the server is the authority + (ADR-0057 D10) — where feasible, prove denial with a direct forged request. +5. **Severe findings are hypotheses.** "The whole surface is unreachable" gets + disproven-or-confirmed via screenshot + the server's own metadata before it is + written down (the golden rule of the dogfood skill). +6. **Don't re-prove what automation pins.** If `automated.ref` is set, run that test + and cite its output as the evidence; drive the browser only for what the pin doesn't + cover. The reverse also holds: when a sweep hand-proves something repeatedly, + propose promoting it to a permanent test and set `automated` in a revision. +7. **Verify pass for high-stakes claims.** For P0 `fail`s and any finding that would + ship or block a release: a second, independent agent re-derives the verdict from the + captured evidence alone (not from the first agent's narrative) before it is acted + on. Disagreement → re-run the item. + +### Trap vocabulary (`traps` field) + +| trap | what it fakes | counter | +|---|---|---| +| `hydration-race` | empty nav/list right after navigation | screenshot first; settle; then read DOM | +| `stale-console-bundle` | UI bug already fixed upstream in objectui | check against objectui HMR console / fresh build (skill §2) | +| `stale-dist` | src edits with no runtime effect | rebuild package + restart before judging | +| `automation-input` | dead buttons / empty submits caused by the driver | ref-targeted clicks; native setter + input/change events | +| `shared-browser-tab` | drifting origin, foreign drafts | pin absolute origin; own port/DB (skill §0) | +| `seed-data-thin` | features with nothing to show; silent seed rejections | check row counts vs built artifact; read boot log | +| `single-datapoint` | charts "render" but prove little | prefer multi-bucket fixtures; note weakness in evidence | +| `dispatcher-vs-hono-route` | route exists in unit tests, 404s on the real server | oracle = live server trace, never simulated dispatch | +| `wrong-panel` | feature looks missing on a sibling surface | item's `steps` name the exact surface; check it | +| `wrong-persona` | admin privileges mask a guard | run guard checks as the non-privileged persona | + +## Run records + +One JSON per executed sweep, written to `runs/YYYY-MM-DD-.json`. **Results are +NOT committed** — a run record is output about one build, not source; `runs/` is +git-ignored except its README (the format contract). Keep the record and its evidence +in the executing environment (CI artifact, runner workspace, the sweep's tracking +issue, or an external QA store). Shape: + +```jsonc +{ + "run": "2026-08-07-v17-release-sweep", + "date": "2026-08-07", + "scope": "since:v17 + P0", // the filter that selected items + "app": "showcase", + "env": { + "framework": "", + "objectuiPin": "<.objectui-sha>", // stale-bundle honesty: record what the console was + "port": 3456, "db": "file:/tmp//data.db" + }, + "runner": "", + "results": [ + { + "id": "approvals.per-group-signoff", + "revision": 1, // ← the revision this verdict is valid for + "verdict": "pass", + "clauses": [ + { "clause": 0, "verdict": "pass", "evidence": "…what was captured, where…" } + ], + "issues": [], // filed failures / fixture gaps + "notes": "…" + } + ] +} +``` + +A run summary for humans may additionally go to the sweep's tracking issue or an +external QA store — but none of it lands in the repo. The durable, version-controlled +truth is the checklist under `areas/`; a run is a dated assertion about a build that +belongs wherever that build's other artifacts live. diff --git a/docs/qa/platform-checklist/SWEEP.md b/docs/qa/platform-checklist/SWEEP.md new file mode 100644 index 0000000000..64e0ffe5e3 --- /dev/null +++ b/docs/qa/platform-checklist/SWEEP.md @@ -0,0 +1,63 @@ +# Coverage sweep runbook — for an AI session, on request + +How to re-run the capability-coverage gap hunt that built and audited this checklist. +A human should only need to say **"跑一轮 coverage sweep"** — everything below is +executable by the AI session itself. Expected cadence: before each major release, or +after any large platform surface lands. + +This is the AI-participation half of keeping the checklist current. The other half is +automatic and needs no human at all: `scripts/check-platform-checklist.mjs` (CI, every +PR) fails when a new metadata **kind** is unmapped (coverage ratchet) or a spec **enum** +grows a value a matrix item was pinned against (`enumSource` freshness ratchet). Those +catch drift on the PR that causes it. This sweep catches the harder class — a whole +surface or behavior nobody wrote an item for — which no deterministic gate can find. + +## What a sweep is + +Five READ-ONLY gap-hunter agents, each enumerating the platform from a different angle +and diffing it against the current checklist. Different angles catch different miss +classes — the 2026-08 sweep's finds (4 factually-stale waivers, the untested built-in +apps, sharing rules, the ACTION_LOCATIONS matrix) each came from a different angle a +single reader would not have covered. + +| angle | enumerate from | catches | +|---|---|---| +| 1. Console UI surfaces | objectui packages (app-shell chrome, plugin-*, e2e specs) | interactions covered piecemeal but never as a surface (drag, guards, personalization, buttons) | +| 2. Spec enums | every `z.enum` / union / const array in packages/spec/src + the formula function lib | behavior-bearing enums with no `variants` matrix | +| 3. Routes & runtime | ALL route ledgers (runtime, rest, service-*, auth) + non-ledgered mounts | routes reachable but semantically untested; dispatcher-vs-hono seams (#3361 class) | +| 4. Built-in apps | packages/apps/{setup,studio,account} page by page | admin/user pages nobody walked; settings/session/org surfaces | +| 5. Docs claims | content/docs/capabilities/*.mdx, release plans, showcase tours | promised capabilities with no item; docs advertising retired features (PD#10) | + +## How to run it + +1. **Read the current state first** — every `areas/*.json`, `coverage.json`, and this + dir's README/RUNNER. The gap is only real if nothing already covers it. +2. **Dispatch the five hunters in parallel**, READ-ONLY (they write no files). Each + returns a structured gap table: `surface | evidence path | current coverage (item id + or NONE/partial) | proposed item id | sketch | stock fixture?`. Give each hunter the + list of already-resolved gaps so they don't re-report. +3. **Dedupe** the five reports into one register (the 2026-08 sweep used a + `PENDING-GAPS.md` scratch file, since deleted). Overlap is expected and is signal — + a gap found from three angles is high-priority. +4. **Author** the new items via per-area writer agents, one area file per writer so + they never collide. Every item follows the deep-test contract in README.md; a fixture + that doesn't exist is a `blocked`/`knownGap`, never faked coverage. +5. **Reconcile `coverage.json` centrally** (a single writer): un-waive any kind a hunter + proved has a stock fixture, map new items to their kinds, pin `enumSource` on any new + variants matrix. +6. **Validate** `node scripts/check-platform-checklist.mjs` until green, then land the + run record under `runs/` and surface product defects / docs drift to the maintainer + in `FOLLOW-UPS.md`. + +## Discipline that made the 2026-08 sweep trustworthy + +- **Ground every claim in source before asserting** — hunters cite real file paths; + writers read the cited source before writing a clause. Several briefs I gave the + writers were factually wrong (`referenceFilters` renamed to `lookupFilters`, + crm-workbench is React not declarative, MCP-off is 404 not 501) and the agents + corrected them against source rather than parroting. +- **Stale waivers are the highest-value find.** Four of six coverage waivers turned out + false (api/datasource/mapping/hook all ship stock fixtures). Re-audit every waiver + each sweep — a waiver is a claim that ages. +- **Defects found while grounding go to FOLLOW-UPS.md as expected-fail probes**, not + silent passes; security-sensitive ones are not filed publicly without the maintainer. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json new file mode 100644 index 0000000000..e290e00848 --- /dev/null +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -0,0 +1,1854 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "access-security", + "title": "Access, permissions, RLS/FLS, write-path guards", + "items": [ + { + "id": "access-security.rls-both-sides", + "title": "Row-level security: restricted member sees only their rows; admin sees all", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P0", + "surface": "api", + "personas": [ + "admin (first sign-in dev admin, platform posture)", + "two plain members (everyone baseline = showcase_member_default only)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_private_note (OWD private, member_default grants create/read/edit)", + "two fresh sign-ups, e.g. rls-a@verify.test / rls-b@verify.test" + ] + }, + "steps": [ + "boot showcase isolated (own port + file DB per dogfood skill §0); sign in as admin; sign up members A and B (fresh emails) — both hold only the everyone baseline showcase_member_default", + "as A: POST /api/v1/data/showcase_private_note {\"title\": \"A note 1\"} and a second note; as B: POST one note — capture the returned ids", + "as A: GET /api/v1/data/showcase_private_note (list); as B: same; capture both record sets", + "as A: GET /api/v1/data/showcase_private_note/ (foreign by-id read); as A: PATCH /api/v1/data/showcase_private_note/ {\"body\": \"forged\"} (foreign by-id write)", + "as admin: GET /api/v1/data/showcase_private_note and confirm the full row set (A's + B's rows) is visible — the entitled side of the gate", + "re-read B's note as admin and confirm the forged PATCH left it byte-identical", + "run the app-agnostic sweep: runRlsProofs from @objectstack/verify (objectstack verify) over every showcase object; capture the per-object verdicts" + ], + "acceptance": [ + { + "clause": "A's list contains exactly A's notes — none of B's (presence AND absence, from the server row set, not the UI)", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_private_note as A: records[] titles include 'A note 1', exclude B's title; symmetric check as B", + "evidence": "both list responses" + }, + { + "clause": "foreign by-id READ is denied server-side (non-2xx) — owner isolation holds at record granularity, not just list filtering", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_private_note/ as A answers non-200 (403 PERMISSION_DENIED or a not-found-shaped denial — capture which; both are honest owner isolation, a 200 is the failure)", + "evidence": "status + body" + }, + { + "clause": "foreign by-id WRITE is denied and the row is unchanged (the #1994 'you can't mutate what you can't see' invariant)", + "oracle": "api", + "verify": "PATCH as A answers >=400 AND the admin re-read of B's note shows the pre-attempt body — a 4xx with a mutated row is still a FAIL", + "evidence": "PATCH response + admin re-read" + }, + { + "clause": "admin (platform posture) reads the full set — the entitled side of the same gate (both sides, RUNNER rule 4 / ADR-0057 D10)", + "oracle": "api", + "verify": "admin GET list contains every id created in this run", + "evidence": "admin listing" + }, + { + "clause": "runRlsProofs reports rls-consistent (or member-visible on deliberately public objects) for every non-skipped showcase object; any rls-hole verdict is a FAIL and files an issue", + "oracle": "test", + "verify": "run runRlsProofs(stack, adminToken, memberToken, config) from packages/verify/src/rls.ts; summary.holes must be 0", + "evidence": "formatRlsReport output" + }, + { + "clause": "skipped objects in the verify report are each explainable (no plain-text probe field / blocked fixture) — a skip hiding a hole is the #3415 seed-defect class", + "oracle": "test", + "verify": "for each status:'skipped' row, the detail names the benign reason; spot-check one skipped object by hand with the by-id read/write probe", + "evidence": "report detail lines + the spot-check trace" + } + ], + "negative": [ + "the foreign by-id write must not silently succeed: a 2xx on the forged PATCH, or a 4xx that still mutated the row (verified by the admin re-read), is a FAIL even though every list looked correctly filtered" + ], + "traps": [ + "wrong-persona" + ], + "automated": { + "kind": "verify", + "ref": "packages/verify/src/rls.ts (objectstack verify) + packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts" + }, + "source": [ + "packages/verify/src/rls.ts", + "ADR-0057 D10", + "packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts", + "authz-conformance.matrix.ts rows rls-read / rls-by-id-write" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — standing P0, delegating the systematic sweep to @objectstack/verify and keeping the persona spot-check manual", + "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" + } + ] + }, + { + "id": "access-security.write-path-guards", + "title": "Write-path guards: readonly strip, owner_id forge/transfer denied, bulk validation", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P0", + "surface": "api", + "personas": [ + "admin", + "two non-admin members (owner forge is only meaningful non-privileged; everyone baseline grants private-note create/edit and contact is reachable read-only, so use a member with showcase_contributor where create is needed)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_contact.lead_score is the seeded readonly:true field (no defaultValue — stripped writes read back null)", + "showcase_private_note carries the owner_id anchor", + "showcase_invoice.tax_rate + line quantity/unit_price/product lock via readonlyWhen record/parent.status == 'paid'" + ] + }, + "steps": [ + "boot showcase isolated; admin session + two members Alice and Bob (fresh sign-ups); resolve their sys_user ids via the data API or objectql", + "as admin (contact create is admin-side in the stock matrix): POST /api/v1/data/showcase_contact {\"name\": \"RO probe\", \"email\": \"ro-probe@verify.test\", \"lead_score\": 10} — forging the readonly field on INSERT; GET the row back", + "PATCH the same contact with {\"lead_score\": 99}; GET again", + "as Alice: POST /api/v1/data/showcase_private_note {\"title\": \"planted\", \"owner_id\": \"\"} (insert forge); then POST a note with {\"owner_id\": \"\"} (explicit self-owner); then PATCH her own note with {\"owner_id\": \"\"} (transfer) and with {\"owner_id\": null} (disown)", + "as Alice: POST /api/v1/data/showcase_private_note/createMany with 3 records carrying no owner_id; GET them back and read owner_id on each", + "as admin: set an invoice to status 'paid', then PATCH its tax_rate and a line's quantity — the readonlyWhen lock (#3042 bulk half: updateMany over a set including the paid invoice's line)", + "bulk-update a set where some rows violate a validation rule (e.g. showcase_invoice_line quantity below min 0 on some rows only) via POST /api/v1/data/showcase_invoice_line/updateMany; read the per-row outcome" + ], + "acceptance": [ + { + "clause": "readonly field is stripped on INSERT — stored value is the default (null for lead_score), never the payload's; the create itself still succeeds (#3043 admit-and-strip, not reject)", + "oracle": "api", + "verify": "POST answers 2xx; GET /api/v1/data/showcase_contact/ shows lead_score null; response header/body droppedFields advertises the strip (#3431)", + "evidence": "payload + read + droppedFields" + }, + { + "clause": "readonly field is stripped on UPDATE — value unchanged after PATCH (#2948/#3003; note: a deployment opting into strictReadonlyWrites refuses instead with ERR_READONLY_FIELD_REJECTED per #5126 — stock showcase is strip)", + "oracle": "api", + "verify": "before/after GETs identical on lead_score", + "evidence": "the reads" + }, + { + "clause": "owner forge on INSERT is DENIED for the member and the row is not created", + "oracle": "api", + "verify": "POST with owner_id= as Alice answers >=400; a system-context count of notes titled 'planted' is 0", + "evidence": "response + filtered count" + }, + { + "clause": "owner transfer and disown on UPDATE are DENIED; owner unchanged — while explicit SELF-owner insert succeeds (the guard gates on identity, not on the key's presence)", + "oracle": "api", + "verify": "PATCH owner_id= and owner_id=null both >=400 with owner_id re-reading as Alice's id; POST with owner_id= is 2xx and persists", + "evidence": "responses + re-reads" + }, + { + "clause": "empty-owner bulk insert stamps the calling member on every row", + "oracle": "api", + "verify": "all createMany rows read back owner_id == Alice's sys_user id", + "evidence": "the reads" + }, + { + "clause": "readonlyWhen locks hold at the API on the locked state: paid invoice's tax_rate and its lines' quantity/unit_price/product are not writable, and a bulk update touching any locked row drops the field for the batch (#3042)", + "oracle": "api", + "verify": "post-write reads show the locked fields unchanged on the paid invoice and its lines", + "evidence": "write payloads + re-reads" + }, + { + "clause": "bulk update evaluates validation rules PER ROW (the #3106 updateMany gap must not reproduce): violating rows rejected/skipped, compliant rows applied — one mixed batch shows both outcomes", + "oracle": "api", + "verify": "updateMany response distinguishes per-row results; re-reads confirm compliant rows changed and violating rows did not", + "evidence": "bulk response + row reads" + } + ], + "negative": [ + "run the forge/transfer clauses as ADMIN too and confirm they SUCCEED where legitimately privileged — the guard must gate on privilege, not break the admin path (wrong-persona trap, both sides)", + "isSystem writes must still set readonly fields (A1's isSystem carve-out): verify via an objectql write with context {isSystem:true} that lead_score IS settable system-side" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts (+ showcase-static-readonly.dogfood.test.ts, showcase-readonly-when-parent.dogfood.test.ts)" + }, + "traps": [ + "wrong-persona" + ], + "source": [ + "#3358 §9", + "release-15.1 plan §A1–A4", + "#3106", + "#5126", + "examples/app-showcase/src/data/objects/contact.object.ts (lead_score)", + "examples/app-showcase/src/data/objects/invoice.object.ts (readonlyWhen)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — merged from #3358 §9 and the 15.1 A-group rows these dogfood tests pin", + "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" + } + ] + }, + { + "id": "access-security.record-access-explain", + "title": "Record-grained access explain shows per-layer attribution and a verdict", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin (drives the panel and the explain API)", + "auditor (viewAllRecords persona whose access is explained)", + "plain member (the deny case)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a showcase_private_note owned by a third user (the probe record)", + "auditor member granted showcase_auditor (sys_user_permission_set row, as showcase-permission-zoo.dogfood.test.ts does)" + ] + }, + "steps": [ + "boot showcase with the console; as admin create the probe: a member-owned showcase_private_note; grant a second member showcase_auditor", + "in the console's access explain panel pick user = the auditor, object = showcase_private_note, record = the probe; screenshot the layered result", + "call the API twin: POST /api/v1/security/explain (client security.explain; GET variant also mounted — rest-route-ledger security-explain family) with the same user/object/record; capture the JSON", + "as the auditor (real session): GET /api/v1/data/showcase_private_note/ — the impersonated-read cross-check", + "repeat panel + API explain for the PLAIN member (no auditor set) on the same record", + "as the plain member: GET the same record and capture the denial" + ], + "acceptance": [ + { + "clause": "the panel attributes access per layer (permission set → position → sharing → row rules) and renders a record.visible verdict naming the deciding layer", + "oracle": "screenshot", + "verify": "screenshot shows all layers with the verdict", + "evidence": "screenshot" + }, + { + "clause": "for the auditor the deciding layer is the VAMA bypass, attributed to the showcase_auditor set (explain() reports the vama_bypass layer — the permission-zoo pinned shape)", + "oracle": "api", + "verify": "POST /api/v1/security/explain response names vama_bypass (or the equivalent layer key) with showcase_auditor as contributor", + "evidence": "explain JSON" + }, + { + "clause": "the explain verdict MATCHES the impersonated read, both ways: auditor verdict visible ∧ auditor GET 200; plain-member verdict not-visible ∧ plain-member GET non-200 (server truth outranks the panel)", + "oracle": "api", + "verify": "compare record.visible against the actual GET status per persona — any disagreement is a FAIL against explain", + "evidence": "explain JSONs + both GET traces" + }, + { + "clause": "the deny-side explain still answers 200 with a structured not-visible result naming the deciding layer — not EXPLAIN_FAILED, not an empty body", + "oracle": "api", + "verify": "plain-member explain response: 200, visible=false, deciding layer named; error code EXPLAIN_FAILED absent", + "evidence": "explain JSON" + }, + { + "clause": "panel and API twin agree (same layers, same verdict) — the console must render the server's explanation, not recompute its own", + "oracle": "api", + "verify": "field-by-field compare of the panel's displayed layers vs the POST /api/v1/security/explain body", + "evidence": "screenshot + JSON diff" + } + ], + "negative": [ + "explain for the plain member must NOT show visible=true merely because the ADMIN is the one asking — the explanation is about the target user, not the caller (wrong-persona); cross-check with the plain member's own denied GET" + ], + "traps": [ + "hydration-race", + "wrong-persona" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (explain vama_bypass case)" + }, + "source": [ + "#3358 §5", + "packages/rest/src/rest-route-ledger.ts (security-explain family)", + "packages/plugins/plugin-security/src/explain-engine.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358, adding the impersonated-read cross-check", + "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" + } + ] + }, + { + "id": "access-security.readonly-package-locks-studio", + "title": "A read-only package actually locks Studio editing surfaces", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin" + ], + "steps": [ + "boot showcase with the console; open Studio → object designer and the permission matrix on an object belonging to a read-only (installed/locked) package", + "screenshot the surface; then read the DOM state of the edit affordances (checkboxes, Save)", + "attempt the edit through the UI anyway (click a checkbox / Save) and capture what the client does", + "forge the write directly: PUT /api/v1/meta/object/ with a trivial field change, as the same admin session", + "repeat the same PUT against a WRITABLE (draft/app-local) object to prove the guard discriminates by package writability, not by blanket denial" + ], + "acceptance": [ + { + "clause": "the read-only badge renders AND the controls are actually disabled — checkboxes/Save inert, edit affordances absent", + "oracle": "dom", + "verify": "after screenshot confirms render, assert disabled state on the controls (a badge alone is not a lock)", + "evidence": "screenshot + disabled-state DOM read" + }, + { + "clause": "the SERVER refuses the same write: direct PUT /api/v1/meta/object/ on the read-only package answers 4xx with a ledgered metadata-protocol code (WRITABLE_PACKAGE_REQUIRED, or ITEM_LOCKED for _lock'd items) — UI absence never suffices (ADR-0057 D10)", + "oracle": "api", + "verify": "PUT response status >=400 and error.code ∈ {WRITABLE_PACKAGE_REQUIRED, ITEM_LOCKED} (packages/spec/src/api/error-code-ledger.zod.ts, @objectstack/metadata-protocol entry)", + "evidence": "PUT trace" + }, + { + "clause": "the same PUT against a writable object SUCCEEDS for the same admin — the lock keys on package writability, not on the route (both sides of the gate)", + "oracle": "api", + "verify": "writable-target PUT answers 2xx and a follow-up GET shows the change", + "evidence": "both traces" + }, + { + "clause": "the denied write leaves the packaged object byte-identical (persistence of the lock)", + "oracle": "api", + "verify": "GET /api/v1/meta/object/ before/after the denied PUT — identical", + "evidence": "the two reads" + } + ], + "negative": [ + "a UI that greys the controls while the direct PUT succeeds is a FAIL of this item even though the screenshot looks correct — record it as a server-guard gap, not a UI polish issue" + ], + "traps": [ + "stale-console-bundle", + "hydration-race" + ], + "source": [ + "#3358 §5", + "packages/spec/src/api/error-code-ledger.zod.ts (@objectstack/metadata-protocol codes)", + "ADR-0010 §3.3 (_lock)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "access-security.crud-permission-matrix", + "title": "CRUD × permission-set matrix: every access-matrix.json row holds — allowed verbs succeed, withheld verbs 403, VAMA bounded", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "api", + "personas": [ + "admin", + "one fresh member per permission set under test (set granted via a sys_user_permission_set row, as showcase-permission-zoo.dogfood.test.ts does)", + "a second member as the foreign-row owner" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "examples/app-showcase/access-matrix.json — it IS the expectation table (31 permission-set × object rows)", + "seeded permission sets showcase_contributor/manager/executive/auditor/ops/member_default/guest_portal/field_ops_delegate (security bootstrap)" + ], + "knownGaps": [ + "every authenticated member ALSO holds the everyone baseline showcase_member_default additively (ADR-0090 D5) — the effective expectation per cell is the UNION of the tested set's row and the baseline's row for that object; compute the union before judging a cell, or a baseline-granted read will look like a matrix violation" + ] + }, + "steps": [ + "boot showcase isolated; admin session; for each permission set in access-matrix.json create a fresh member (unique email) and grant exactly that set by inserting the sys_user_permission_set row (system context), mirroring showcase-permission-zoo.dogfood.test.ts", + "as admin (or a helper member), seed one FOREIGN-owned probe row per object under test (e.g. a showcase_private_note, showcase_inquiry, showcase_invoice + line, showcase_announcement owned by someone other than the persona)", + "for every row of access-matrix.json drive all four verbs as that persona: POST /api/v1/data/ {minimal valid payload}, GET /api/v1/data/ + GET /api/v1/data//, PATCH /api/v1/data//, DELETE /api/v1/data// — capture status + error.code for each cell", + "for rows with viewAllRecords:true (auditor on showcase_inquiry/showcase_invoice/showcase_invoice_line/showcase_private_note): GET the foreign probe by id and list; for the SAME objects re-run as a persona whose row says viewAllRecords:false", + "for rows with modifyAllRecords:true (ops on showcase_announcement): PATCH the foreign-owned announcement; re-run the same PATCH as member_default (modifyAllRecords:false)", + "for the guest_portal row pair on showcase_inquiry (create:true, read:false): POST an inquiry as that persona, then GET the list and the created id", + "record the full verb × set × object verdict matrix and diff it against access-matrix.json (unioned with the baseline per the known gap above)" + ], + "acceptance": [ + { + "clause": "every allowed cell succeeds: for each access-matrix.json row, verbs marked true answer 2xx and the effect persists (created row readable, patched field re-reads changed, deleted row gone)", + "oracle": "api", + "verify": "per-cell status < 300 plus a follow-up read proving the effect", + "evidence": "the verdict matrix + spot re-reads" + }, + { + "clause": "every withheld cell is DENIED SERVER-SIDE with the ledgered code: verbs marked false answer 403 with error.code PERMISSION_DENIED (rest-server maps explicit security denials to 403 PERMISSION_DENIED) — UI absence never counts (ADR-0057 D10)", + "oracle": "api", + "verify": "per-cell status 403 and body code PERMISSION_DENIED; capture any cell answering a different code for triage", + "evidence": "the verdict matrix" + }, + { + "clause": "a denied CREATE leaves no row behind (persistence of the denial)", + "oracle": "api", + "verify": "system-context count of rows matching the denied payload's unique marker is 0", + "evidence": "filtered count" + }, + { + "clause": "viewAllRecords:true bypasses OWD/sharing on exactly the named objects — the auditor reads the foreign private note/inquiry/invoice/line by id AND in lists; a persona without the bit gets the foreign row neither way", + "oracle": "api", + "verify": "auditor GETs 200 with the probe present; the contrast persona's by-id GET non-200 and list excludes it", + "evidence": "both personas' traces" + }, + { + "clause": "modifyAllRecords:true grants foreign WRITE only where held: ops PATCHes anyone's announcement (public_read OWD, owner-writes baseline) with 2xx + persisted change; member_default's identical PATCH is denied and the row unchanged", + "oracle": "api", + "verify": "ops PATCH 2xx + re-read; member PATCH >=400 + unchanged re-read", + "evidence": "both traces + re-reads" + }, + { + "clause": "the write-only intake asymmetry holds: guest_portal's showcase_inquiry row (create:true, read:false) accepts the POST but denies reading it back — create must not imply read", + "oracle": "api", + "verify": "POST 2xx; subsequent GET list/by-id as the same persona non-200 or excludes the row", + "evidence": "POST + read traces" + }, + { + "clause": "every variant (permission set) is driven over EVERY object row access-matrix.json lists for it, and the run record carries one verdict per cell — a set skipped or an object row skipped makes the item at best partial", + "oracle": "api", + "verify": "verdict matrix dimensions match access-matrix.json entries (31 rows × 4 verbs at revision-time; recount from the file each run)", + "evidence": "the matrix artifact" + } + ], + "negative": [ + "run one denied cell per set as ADMIN and confirm it succeeds — proves the denial came from the persona's grants, not from a broken route (wrong-persona, both sides)", + "any withheld cell answering 2xx is a FAIL even if the created/changed data looks harmless; silent success is the defect" + ], + "variants": [ + "showcase_contributor", + "showcase_manager", + "showcase_executive", + "showcase_auditor", + "showcase_ops", + "showcase_member_default", + "showcase_guest_portal", + "showcase_field_ops_delegate" + ], + "automated": { + "kind": "verify", + "ref": "packages/verify/src/verify.ts (runCrudVerification) + packages/verify/src/rls.ts (runRlsProofs) — objectstack verify; persona-grained cells remain manual" + }, + "traps": [ + "wrong-persona", + "seed-data-thin" + ], + "source": [ + "examples/app-showcase/access-matrix.json (variant source — the expectation table)", + "examples/app-showcase/src/security/permission-sets.ts", + "ADR-0090 D1/D5", + "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — CRUD × permission matrix grounded in access-matrix.json, per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.owd-sharing-matrix", + "title": "Sharing-model / OWD matrix: private, public_read, public_read_write, controlled_by_parent each enforce their declared baseline", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "api", + "personas": [ + "admin", + "members A and B (baseline only)", + "contributor members for the invoice/line and project cases", + "ops (modifyAllRecords contrast on announcements)", + "auditor (viewAllRecords contrast on lines)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "one seeded object per model, from the access-matrix sharingModel column: showcase_private_note (private), showcase_announcement (public_read), showcase_account + showcase_project (public_read_write), showcase_invoice_line under showcase_invoice (controlled_by_parent)", + "contributor invoice RLS owner == current_user.email (permission-sets.ts invoice_own_rows) — invoices must be created with owner set to the creating contributor's email" + ] + }, + "steps": [ + "boot showcase isolated; create members A and B (baseline), two contributor members C1/C2 (grant showcase_contributor), an ops member, an auditor member", + "private: as A create a showcase_private_note; as B list, GET by id, PATCH it", + "public_read: as admin (or ops) create a showcase_announcement owned by someone other than B; as B GET list + by id, then PATCH it; as ops PATCH the same announcement (modifyAllRecords contrast)", + "public_read_write: as admin create a showcase_project; as C1 (allowEdit:true on projects, no project RLS) GET it and PATCH a non-FLS field (e.g. name suffix) — record-level write open by OWD; as A (member_default, allowEdit:false) attempt the same PATCH — the object-level bit must still gate", + "controlled_by_parent: as C1 POST /api/v1/data/showcase_invoice {\"name\": \"INV-C1-1\", \"owner\": \"\", \"status\": \"draft\"} then POST /api/v1/data/showcase_invoice_line {\"invoice\": , \"product\": , \"quantity\": 1}; as C2 list lines, GET C1's line by id, PATCH it", + "as C1: GET/PATCH their OWN line by id (the entitled side of ADR-0055 derivation)", + "as auditor: list showcase_invoice_line — viewAllRecords crosses the derived scope", + "capture per-model, per-persona verb outcomes" + ], + "acceptance": [ + { + "clause": "private: only the owner reads/writes — B's list excludes A's note, B's by-id GET and PATCH are non-2xx, A's own read/write 2xx", + "oracle": "api", + "verify": "the four traces; PATCH denial confirmed unchanged by admin re-read", + "evidence": "traces + re-read" + }, + { + "clause": "public_read: everyone reads, only owner writes — B GET 200 (list and by id) but B PATCH >=400 with the row unchanged", + "oracle": "api", + "verify": "B's GET/PATCH traces + admin re-read of the announcement", + "evidence": "traces + re-read" + }, + { + "clause": "public_read: modifyAllRecords crosses the owner-writes baseline — ops' PATCH of the same foreign announcement is 2xx and persists (the bypass matters exactly where the baseline stops)", + "oracle": "api", + "verify": "ops PATCH 2xx + re-read shows the change", + "evidence": "trace + re-read" + }, + { + "clause": "public_read_write: record-level write is open — C1 (holding allowEdit on showcase_project) PATCHes a project they do not own with 2xx; the object-level bit still gates: A's identical PATCH (member_default allowEdit:false) answers 403 PERMISSION_DENIED", + "oracle": "api", + "verify": "both PATCH traces; the OWD opens records, never verbs the set withholds", + "evidence": "both traces" + }, + { + "clause": "controlled_by_parent: line access derives from the master (ADR-0055) — C2 cannot list, read by id, or PATCH C1's line (C1's invoice is outside C2's owner-RLS read set); C1 reads and writes their own line by id", + "oracle": "api", + "verify": "C2's three denials (list excludes, by-id non-200, PATCH >=400 + unchanged) and C1's 2xx pair; no line-level rule is authored — derivation is the only mechanism in play", + "evidence": "all traces" + }, + { + "clause": "controlled_by_parent + VAMA: the auditor's viewAllRecords on showcase_invoice_line surfaces every line regardless of master ownership", + "oracle": "api", + "verify": "auditor list contains C1's line", + "evidence": "auditor listing" + }, + { + "clause": "all four model variants are exercised and each verdict is recorded per persona-verb — a model not driven leaves the item partial", + "oracle": "api", + "verify": "run record carries verdicts for private / public_read / public_read_write / controlled_by_parent", + "evidence": "run record" + } + ], + "negative": [ + "the public_read foreign PATCH must fail server-side even when the console hides the edit button — drive it as a forged direct request; a 2xx there is a FAIL (D10)", + "run C2's line probes ALSO as C1 to prove the denial is derivation, not a broken line route (wrong-persona both sides)" + ], + "variants": [ + "private", + "public_read", + "public_read_write", + "controlled_by_parent" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-private-owd.dogfood.test.ts + showcase-public-read-owd.dogfood.test.ts + controlled-by-parent.dogfood.test.ts + showcase-invoice-cbp.dogfood.test.ts" + }, + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/spec/src/security/sharing.zod.ts (the four-model enum — variant source)", + "examples/app-showcase/access-matrix.json (sharingModel column)", + "ADR-0055", + "authz-conformance.matrix.ts rows owd-private / owd-public-read / controlled-by-parent" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — OWD matrix over the four spec sharing models, per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.fls-mask-and-strip", + "title": "Field-level security: editable:false strips/denies writes API-side and renders read-only in the UI; masked-read half needs an authored readable:false grant", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "contributor (the FLS-restricted persona: showcase_project.budget/spent/budget_remaining readable:true, editable:false)", + "admin (the unrestricted contrast)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_contributor.fields FLS on showcase_project budget figures (permission-sets.ts — keys are . qualified; bare keys silently enforce nothing, the compile-time lint security-fls-unqualified-key guards that)", + "a seeded showcase_project row with a non-null budget" + ], + "knownGaps": [ + "stock showcase authors NO readable:false FLS grant, so the read-MASKING half (field absent/nulled on GET, plugin-security/src/field-masker.ts) has no stock fixture; to run it, author a scratch permission set carrying readable:false on a showcase_project field and grant it to a fresh member — if the run cannot author one through a supported surface, record that half blocked(fixture) rather than ticking on the write half alone" + ] + }, + "steps": [ + "boot showcase with the console; contributor member (grant showcase_contributor); pick a seeded showcase_project and record its budget via a system-context read", + "as contributor over the API: PATCH /api/v1/data/showcase_project/ {\"name\": \" (edited)\"} — an editable field, then PATCH {\"budget\": 999999999} — the FLS-locked field", + "re-read the project system-side; compare budget before/after", + "as admin: PATCH the same budget field to a new value and re-read (the entitled side)", + "in the console as the contributor: open the project's detail/edit form; screenshot; inspect the budget/spent/budget_remaining controls' state", + "read-mask half (see knownGaps): grant a scratch readable:false set to a fresh member, then GET the project as that member and inspect whether the masked field is absent/nulled in the API body AND blank in the UI render", + "as admin GET the same row — the unmasked contrast" + ], + "acceptance": [ + { + "clause": "editable field write succeeds for the contributor (the FLS lock is per-field, not per-object)", + "oracle": "api", + "verify": "name PATCH 2xx and re-reads changed", + "evidence": "trace + re-read" + }, + { + "clause": "editable:false field write is refused/stripped for the contributor and the stored value is unchanged (the permission-zoo pinned behavior: status >= 400 and budget identical)", + "oracle": "api", + "verify": "budget PATCH answers >=400 (or a documented strip) AND the system-context re-read equals the pre-write budget — the value oracle decides, not the status alone", + "evidence": "trace + before/after reads" + }, + { + "clause": "the SAME write succeeds for admin — the lock keys on the caller's FLS, not on the field (both sides)", + "oracle": "api", + "verify": "admin budget PATCH 2xx + persisted re-read", + "evidence": "trace + re-read" + }, + { + "clause": "the UI renders the FLS state faithfully for the contributor: budget figures visible (readable:true) but not editable — input disabled/read-only on the edit form", + "oracle": "dom", + "verify": "after a screenshot confirms the form rendered, assert the disabled/read-only state of the three budget controls", + "evidence": "screenshot + DOM read" + }, + { + "clause": "read masking (given the scratch readable:false grant): the masked field is absent or nulled in the member's API read AND blank in their UI render, while admin's read carries the value — API and UI agree, both personas", + "oracle": "api", + "verify": "member GET body lacks/nulls the field; admin GET carries it; UI screenshots per persona match their API bodies", + "evidence": "both GET bodies + screenshots" + } + ], + "negative": [ + "a UI-only lock is a FAIL: if the form disables the control but the direct PATCH mutates budget, record a server-guard gap (the API clause is the oracle, the DOM clause is corroboration only)" + ], + "traps": [ + "wrong-persona", + "stale-console-bundle", + "hydration-race" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts (FLS budget case — write half only)" + }, + "source": [ + "examples/app-showcase/src/security/permission-sets.ts (contributor FLS)", + "packages/plugins/plugin-security/src/field-masker.ts (read-mask enforcement site)", + "ADR-0090 D10 (mask intersection)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — FLS item per the deep-test contract; read-mask half carries an explicit fixture gap instead of an ungrounded step", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.scope-depth-asymmetry", + "title": "Scope depth (readScope/writeScope): org-wide read with own-only write, per persona — depth widens along geometry, never bypasses", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "plain member M (creates the foreign probe inquiries via member_default create:true)", + "manager (readScope org / writeScope own on showcase_inquiry)", + "executive (readScope org, no write, on showcase_inquiry + showcase_private_note)", + "ops (readScope org / writeScope org on showcase_inquiry)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_inquiry is OWD private with create granted to the baseline — depth only matters where the baseline stops (permission-sets.ts docblock)", + "personas staffed by granting the sets (sys_user_permission_set) or assigning the bound positions manager/exec/ops (bind-position-sets.ts wires position → set at boot)" + ] + }, + "steps": [ + "boot showcase isolated; create member M plus manager/executive/ops personas; as M: POST /api/v1/data/showcase_inquiry {\"subject\": \"M probe\", ...minimal valid payload}; as the manager: POST their OWN inquiry", + "as the manager: GET /api/v1/data/showcase_inquiry (list) — M's inquiry must appear (readScope org on a private object)", + "as the manager: PATCH M's inquiry (foreign write, writeScope own) and PATCH their own inquiry", + "as the executive: GET the inquiry list and M's inquiry by id; then attempt PATCH (no edit bit at all); also GET /api/v1/data/showcase_private_note list (readScope org over private notes)", + "as ops: PATCH M's inquiry (writeScope org)", + "as M: GET the manager's inquiry by id — the baseline member must NOT get org-wide read (the contrast that proves depth did the widening)", + "capture all traces + admin re-reads after every denied write" + ], + "acceptance": [ + { + "clause": "manager reads org-wide on the private object: M's inquiry present in the manager's list and readable by id", + "oracle": "api", + "verify": "GET list contains 'M probe'; by-id GET 200", + "evidence": "traces" + }, + { + "clause": "manager's write stays own-scoped (the read/write ASYMMETRY): PATCH on M's inquiry >=400 with the row unchanged; PATCH on the manager's own inquiry 2xx", + "oracle": "api", + "verify": "both PATCH traces + admin re-read of M's inquiry", + "evidence": "traces + re-read" + }, + { + "clause": "executive reads org-wide (inquiries AND private notes) but cannot write at all — every PATCH >=400, rows unchanged", + "oracle": "api", + "verify": "list/by-id GETs 200 with foreign rows present; PATCH >=400 + unchanged re-read", + "evidence": "traces + re-read" + }, + { + "clause": "ops writes org-wide: PATCH on M's inquiry 2xx and persisted (the entitled side of the exact guard that denied the manager)", + "oracle": "api", + "verify": "ops PATCH 2xx + re-read shows the change", + "evidence": "trace + re-read" + }, + { + "clause": "the baseline member has NO org-wide read: M cannot read the manager's inquiry by id and M's list holds only M's own — depth was the widener, not the object or route", + "oracle": "api", + "verify": "M's by-id GET non-200; M's list excludes the manager's inquiry", + "evidence": "traces" + }, + { + "clause": "each persona variant is driven and recorded (manager / executive / ops read+write outcomes) — the asymmetry table in the run record matches the readScope/writeScope columns of access-matrix.json", + "oracle": "api", + "verify": "diff the recorded outcomes against the access-matrix.json readScope/writeScope annotations for showcase_manager/showcase_executive/showcase_ops", + "evidence": "the outcome table" + } + ], + "negative": [ + "a manager PATCH of M's inquiry that answers 2xx is a FAIL even if some UI would have hidden the row — writeScope own must be enforced on the forged direct request; and the same PATCH as ops must SUCCEED, or the finding is a broken route rather than a working guard (wrong-persona, both sides)" + ], + "variants": [ + "manager: read org / write own", + "executive: read org / write none", + "ops: read org / write org" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts (+ showcase-scope-depth-write, showcase-scope-depth-fallback)" + }, + "traps": [ + "wrong-persona" + ], + "source": [ + "examples/app-showcase/src/security/permission-sets.ts (ADR-0057 D1 dials)", + "examples/app-showcase/access-matrix.json (readScope/writeScope columns — variant source)", + "ADR-0057 D1" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — scope-depth read/write asymmetry matrix, per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.anonymous-deny-surfaces", + "title": "Anonymous requests to every mounted API family answer 401 UNAUTHENTICATED — uniformly, before any resource resolution", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "api", + "personas": [ + "anonymous (no Authorization header)", + "an authenticated member (the unaffected contrast)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "stock secure default — boot with NO requireAuth override, exactly as the dogfood pin does; the platform default is what a fresh production deployment gets" + ] + }, + "steps": [ + "boot showcase on the platform-default auth posture; do NOT sign in for the probe half", + "fire unauthenticated requests, one per mounted surface family: GET /api/v1/data/showcase_private_note (data), GET /api/v1/meta (metadata), POST /api/v1/actions/showcase_task/showcase_mark_done/anon-probe-id (dispatcher actions — deliberately a NONEXISTENT record id), GET /api/v1/automation (dispatcher automation), POST /api/v1/batch {\"operations\": []} (batch), GET /api/v1/security/explain (security-explain)", + "capture status + full body per surface", + "classify each 401 body into exactly ONE of the two declared envelope families (rest-flat vs dispatcher-wrapper, #5632) — no tolerant cross-family reads", + "sign in as a member and repeat the data + meta reads to prove the gate keys on anonymity, not on the routes", + "fetch a declared-public route (rest-route-ledger disposition 'public', forms family) anonymously — the gate must not over-deny it" + ], + "acceptance": [ + { + "clause": "every probed surface answers HTTP 401 with error code UNAUTHENTICATED (ANONYMOUS_DENY_STATUS/ANONYMOUS_DENY_CODE from @objectstack/core) — no surface differs", + "oracle": "api", + "verify": "all captured statuses == 401 and each body's code == 'UNAUTHENTICATED'", + "evidence": "the per-surface traces" + }, + { + "clause": "denial happens BEFORE resource resolution: the actions probe with a nonexistent record id still answers 401 (never 404) — an anonymous caller must not learn the route's shape (#5519: the gate is the handler's first statement)", + "oracle": "api", + "verify": "POST /api/v1/actions/showcase_task/showcase_mark_done/anon-probe-id → 401, not 404/400", + "evidence": "trace" + }, + { + "clause": "every 401 body classifies into exactly one of the two declared envelope families — a third dialect (hybrid/re-nested body) is a FAIL even though the status is right (#5632)", + "oracle": "api", + "verify": "mutually exclusive family predicates as in showcase-anonymous-deny-surfaces.dogfood.test.ts: rest-flat (top-level error string, no success flag) for /data /meta /batch /security, dispatcher-wrapper for /actions /automation", + "evidence": "classified bodies" + }, + { + "clause": "an authenticated member is unaffected: the same data/meta reads answer 200 for a signed-in baseline member", + "oracle": "api", + "verify": "member GET /api/v1/data/showcase_private_note and GET /api/v1/meta → 200", + "evidence": "member traces" + }, + { + "clause": "declared-public surfaces stay public: a rest-route-ledger 'public' route (anonymous forms) answers without the 401 — the deny must gate on the route's declared posture, not blanket the server (both sides of the gate)", + "oracle": "api", + "verify": "anonymous fetch of a forms-family public route answers non-401", + "evidence": "trace" + }, + { + "clause": "every surface variant is probed and recorded; a family not driven (e.g. only /data checked) leaves the item partial — the #5519 lesson is exactly that sibling surfaces drifted while /data looked fine", + "oracle": "api", + "verify": "run record carries one verdict per variant below", + "evidence": "run record" + } + ], + "negative": [ + "the destructive automation case must be denied too: anonymous DELETE /api/v1/automation/showcase_reassign_wizard answers 401 and the flow remains registered afterwards (verify by an authed GET /api/v1/automation listing it) — a 200 {deleted:true} is the exact #5519 regression" + ], + "variants": [ + "data (/api/v1/data)", + "metadata (/api/v1/meta)", + "actions (/api/v1/actions — dispatcher-mounted)", + "automation (/api/v1/automation — dispatcher-mounted)", + "batch (/api/v1/batch)", + "security-explain (/api/v1/security/explain)" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts" + }, + "traps": [ + "dispatcher-vs-hono-route" + ], + "source": [ + "release-15.1 plan §A8", + "#2567", + "#5519/#5569/#5570", + "#5632", + "authz-conformance.matrix.ts anonymous-deny rows (covers keys)", + "packages/core/src/security/anonymous-deny.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — auth-boundary sweep over every mounted family, grounded in the #2567/#5519 conformance rows and their dogfood pin", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.sharing-rules-widen", + "title": "Criteria sharing rules widen the OWD baseline: matching rows become visible to the rule's audience, non-matching stay hidden", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "a member in the rule's audience (position / unit_and_subordinates)", + "a member NOT in the audience", + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped criteria sharing rules + sys_business_unit tree (examples/app-showcase/src/security/)", + "runtime-assigned exec/manager/Field-Ops personas (as access-security.scope-depth-asymmetry already provisions)" + ], + "knownGaps": [ + "the owner-based rule is [experimental] and skipped per the liveness ledger — exclude it; criteria + position/unit audiences are the live surface" + ] + }, + "variants": [ + "criteria rule, position audience", + "criteria rule, unit_and_subordinates audience", + "manual one-off share via the record-shares route" + ], + "steps": [ + "boot showcase isolated; assign the exec/manager/Field-Ops positions to distinct runtime members", + "as admin create a MATCHING probe record (satisfies a rule's criteria, e.g. a high-value / red project or a matching inquiry) and a NON-MATCHING probe on the same private-OWD object", + "as the audience member: GET the object list and GET each probe by id", + "as a member OUTSIDE the audience: same reads", + "read sys_record_share for the matching record; run /security/explain for the audience member + matching record", + "manual-share the non-matching record to a specific user via the record-shares route; that user re-reads", + "§A13: as admin edit a materialized share, restart the server, re-read (seed-not-clobber)" + ], + "acceptance": [ + { + "clause": "the audience member reads the MATCHING record (list + by-id) though the object OWD is private — the rule widened access", + "oracle": "api", + "verify": "audience member's list contains the matching id and the by-id GET is 200", + "evidence": "the reads" + }, + { + "clause": "the NON-matching record stays invisible to the same audience member, and the matching record stays invisible to a member outside the audience — widening is scoped, both sides", + "oracle": "api", + "verify": "non-matching by-id GET is denied for the audience member; matching by-id GET is denied for the outsider", + "evidence": "the four reads" + }, + { + "clause": "the widening is materialized: a sys_record_share row exists for the matching record and the explain output names the SHARING layer as the deciding grant (not OWD, not position)", + "oracle": "api", + "verify": "sys_record_share read + /security/explain attribution", + "evidence": "the row + explain" + }, + { + "clause": "a manual one-off share grants exactly the target user and no one else (both sides)", + "oracle": "api", + "verify": "target reads the record; a third user still cannot", + "evidence": "the two reads" + }, + { + "clause": "§A13 — an admin edit to a materialized/seeded share survives a restart (the seed does not clobber it, #2909)", + "oracle": "api", + "verify": "post-restart re-read shows the admin's edit intact", + "evidence": "before/after-restart reads" + } + ], + "negative": [ + "a non-matching record becoming visible to the audience (over-broad rule), or a matching record leaking to an outsider, is a FAIL; a share silently dropped on restart is the #2909 regression" + ], + "traps": [ + "wrong-persona", + "seed-data-thin" + ], + "source": [ + "examples/app-showcase/src/security/ (criteria rules + sys_business_unit tree)", + "content/docs/capabilities/permissions.mdx (layer 3 sharing), showcase_tour_security ('Widening')", + "docs/plans/release-15.1-test-plan.md §A13 (#2909)", + "packages/plugins/plugin-sharing (sys_record_share materialization, ADR-0055)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — THE marquee gap: sharing rules (criteria → materialized sys_record_share) had zero behavioral coverage though showcase ships stock fixtures; owd-sharing-matrix only did baselines", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.record-share-grant-revoke", + "title": "Per-record manual shares grant, scope, and revoke access on a private-OWD record; rule evaluate reconciles the audience", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "member A (owner of the private-OWD probe note)", + "member B (the grantee — baseline only, no relation to A)", + "member C (a third baseline member — the isolation contrast)", + "admin (drives the rule evaluate + system-context re-reads)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_private_note (sharingModel: private — member_default grants create/read/edit, so a non-owner sees nothing without a share)", + "three fresh sign-ups (A/B/C) whose sys_user ids are resolved via a system-context read", + "the seeded criteria sharing rule share_red_projects_with_execs on showcase_project + at least one red-health showcase_project (examples/app-showcase/src/security/sharing-rules.ts) for the evaluate clause" + ] + }, + "steps": [ + "boot showcase isolated (own port + file DB, dogfood skill §0); admin session + members A/B/C (fresh emails); resolve their sys_user ids system-side", + "as A: POST /api/v1/data/showcase_private_note {\"title\": \"A share probe\"} — capture the id", + "as B: GET /api/v1/data/showcase_private_note/ — the PRE-grant baseline (private OWD, B is not owner → must be refused)", + "as A: POST /api/v1/data/showcase_private_note//shares {\"recipientType\": \"user\", \"recipientId\": \"\", \"accessLevel\": \"read\"} — capture the 201 sys_record_share row (source 'manual')", + "as B: GET the note by id AND GET /api/v1/data/showcase_private_note (list) — the grant must make it visible both ways", + "as C: GET the note by id — still refused (the grant named B, not everyone)", + "as A: GET /api/v1/data/showcase_private_note//shares (management-gated list) — the manual grant is present", + "as B (a non-manager on A's note): POST /api/v1/data/showcase_private_note//shares granting themselves — must be refused (creating a share is not a reader's power, ADR-0111 D5)", + "as A: DELETE /api/v1/data/showcase_private_note//shares/ → 204; then as B: GET the note by id again — refused once more", + "revoke-scope probe: attempt DELETE of the same shareId through a DIFFERENT record's path (/data/showcase_private_note//shares/) — must be refused (the URL's object/id is the revoke scope, ADR-0111 D4)", + "as admin: POST /api/v1/sharing/rules/share_red_projects_with_execs/evaluate — capture the reconcile result; read sys_record_share for the matched red project" + ], + "acceptance": [ + { + "clause": "PRE-grant isolation holds: B's by-id GET of A's private note is non-2xx before any share exists (the baseline the grant then widens)", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_private_note/ as B answers non-200 (403 PERMISSION_DENIED or a not-found-shaped denial) BEFORE the POST /shares", + "evidence": "the pre-grant read" + }, + { + "clause": "the manual grant lands as a sys_record_share row: POST .../shares answers 201 with recipient_type 'user', recipient_id B, access_level 'read', source 'manual', granted_by A", + "oracle": "api", + "verify": "POST /api/v1/data/showcase_private_note//shares status 201; the returned/re-read sys_record_share row carries those fields (packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts)", + "evidence": "the POST response + a system-context sys_record_share read" + }, + { + "clause": "the grant widens B's read BOTH ways and stays scoped: after the grant B reads the note by id (200) AND it appears in B's list, while C (ungranted) still cannot read it by id", + "oracle": "api", + "verify": "B by-id GET 200 + B list contains the id; C by-id GET non-200 and C list excludes it", + "evidence": "B's two reads + C's two reads" + }, + { + "clause": "the shares list is management-gated: A (owner/manager) lists the record's shares, but B (visible-but-not-manager) POSTing a share is refused 403 (or 404 when the record is invisible) — reading a record does not confer re-share authority (ADR-0111 D5)", + "oracle": "api", + "verify": "A GET .../shares 200 with the grant present; B POST .../shares >=400 (403 visible-not-manager / 404 invisible)", + "evidence": "A's list + B's refused POST" + }, + { + "clause": "revoke retracts access: DELETE .../shares/:shareId answers 204 and B's next by-id GET is non-2xx again — access tracks the grant lifecycle, not a cached decision", + "oracle": "api", + "verify": "DELETE 204; B by-id GET after revoke non-200; a system-context sys_record_share read shows the row gone", + "evidence": "DELETE trace + B's post-revoke read" + }, + { + "clause": "revoke is record-scoped: revoking the share id through a different record's path is refused — a share can only be revoked through the record it belongs to (ADR-0111 D4)", + "oracle": "api", + "verify": "DELETE /data/showcase_private_note//shares/ answers >=400 and the share still exists (re-read)", + "evidence": "the mis-scoped DELETE trace + survival read" + }, + { + "clause": "rule evaluate reconciles the audience: POST /sharing/rules/share_red_projects_with_execs/evaluate returns {ruleId, matchedRecords>=1, grantsCreated/grantsUpdated} and a sys_record_share row exists for the matched red project with source 'rule' and source_id the rule name", + "oracle": "api", + "verify": "the SharingRuleEvaluationResult body (packages/plugins/plugin-sharing/src/sharing-rule-service.ts evaluateRule) + the materialized rule-sourced share row", + "evidence": "evaluate response + the sys_record_share read" + } + ], + "negative": [ + "a revoked share whose record still reads 200 for B, or a grant that leaks to C, is a FAIL even though the manual grant looked correct — access must equal the live grant set", + "a non-manager (B) successfully POSTing a share on A's note is the ADR-0111 D5 authority gap — a 2xx there is a FAIL" + ], + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/rest/src/rest-route-ledger.ts (record-shares family: GET/POST /data/:object/:id/shares, DELETE .../:shareId; sharing-rules family evaluate)", + "packages/rest/src/rest-server.ts (registerRecordShareEndpoints ~L7246-7331; registerSharingRuleEndpoints evaluate ~L7477-7493)", + "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts (recipient_type/recipient_id/access_level/source fields)", + "packages/plugins/plugin-sharing/src/sharing-rule-service.ts (evaluateRule → SharingRuleEvaluationResult)", + "examples/app-showcase/src/security/sharing-rules.ts (share_red_projects_with_execs seeded criteria rule)", + "ADR-0111 D1/D4/D5" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — per-record manual share grant/scope/revoke lifecycle on showcase_private_note plus rule-evaluate reconcile, grounded in the ADR-0111 record-shares routes", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.audit-log-browser", + "title": "The audit-log browser surfaces attributable events over sys_audit_log with correct actor/object, filters, and a before/after payload drawer", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin (drives the /system/audit-log page and the delete + settings write)", + "a fresh member (generates the attributable login event)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "sys_audit_log (plugin-audit; enable.apiMethods = get + list only — writes happen only through internal system hooks)", + "the console audit-log page at /system/audit-log (objectui apps/console/src/pages/system/AuditLogPage.tsx → /api/v1/data/sys_audit_log)", + "a deletable seeded record (e.g. a showcase_task) and a writable settings namespace for the config_change event" + ] + }, + "steps": [ + "boot showcase with the console; admin session", + "generate 3 attributable events with distinct action/actor/object: (a) a fresh member SIGNS IN (action 'login', actor = the member); (b) as admin DELETE a showcase_task via /api/v1/data/showcase_task/ (action 'delete', object_name showcase_task, record_id set); (c) write a setting (action 'config_change')", + "open /system/audit-log; wait for the table to settle; screenshot", + "locate all three events in the page and read their action / object / actor cells", + "set the Action filter to 'delete' and confirm only the delete event remains; capture the re-issued /api/v1/data/sys_audit_log request with its $filter", + "click the delete row → the side drawer opens; screenshot the Before (old_value) / After (new_value) JSON panels", + "cross-check the API twin directly: GET /api/v1/data/sys_audit_log?$filter=... for each of the three actions and compare actor/object/action to the page", + "attempt to forge the trail: POST and PATCH /api/v1/data/sys_audit_log — both must be refused (get+list only)" + ], + "acceptance": [ + { + "clause": "all three ops produce audit rows with the correct action, actor and target: login→action 'login' attributed to the member; delete→action 'delete' with object_name showcase_task + record_id; settings write→action 'config_change'", + "oracle": "api", + "verify": "GET /api/v1/data/sys_audit_log returns the three rows; action/actor(user_id)/object_name/record_id match what each op did (fields per packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts)", + "evidence": "the three audit rows" + }, + { + "clause": "the browser renders those same rows: after a screenshot confirms the table painted, the DOM rows carry the same action/actor/object the API returned — the page shows server truth, not a recomputation", + "oracle": "dom", + "verify": "post-screenshot, the three rows' Action/Object/Actor cells equal the API values (read the DOM only after render is confirmed — hydration-race)", + "evidence": "screenshot + the row DOM read" + }, + { + "clause": "the Action filter narrows to exactly one event, and the narrowing is server-side", + "oracle": "network", + "verify": "setting Action=delete re-issues GET /api/v1/data/sys_audit_log with $filter carrying action=delete; the table then shows only the delete event", + "evidence": "the filtered request + the single-row table" + }, + { + "clause": "the drawer shows the change payload as Before/After JSON — old_value and new_value pretty-printed, not just a label (recorded faithfully as a two-panel before/after, NOT a merged semantic diff)", + "oracle": "screenshot", + "verify": "the row drawer renders old_value under 'Before' and new_value under 'After' as JSON where the event carries them", + "evidence": "the drawer screenshot annotated 'before/after panels, not a diff'" + }, + { + "clause": "the API twin reconciles with the page: GET /api/v1/data/sys_audit_log returns the same three events with matching actor/object — a page row without a backing API row (or vice versa) is a FAIL", + "oracle": "api", + "verify": "field-by-field compare of the page's three rows against the /api/v1/data/sys_audit_log bodies", + "evidence": "API list vs page rows" + }, + { + "clause": "the trail is append-only via the data API: POST and PATCH /api/v1/data/sys_audit_log are refused (enable.apiMethods = get + list) — an actor cannot forge or edit their own audit record", + "oracle": "api", + "verify": "POST and PATCH both answer >=400 (method not permitted for the object); a follow-up list shows no forged row", + "evidence": "the two write traces + the list" + } + ], + "negative": [ + "a login / delete / config_change that leaves NO audit row is the #3415-class silent-seed/hook failure — a FAIL against the audit hook, not a fixture block", + "a successful POST/PATCH to sys_audit_log via the data API is a FAIL — append-only must hold at the server, not only in the read-only page" + ], + "traps": [ + "hydration-race", + "stale-console-bundle" + ], + "source": [ + "packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts (action enum, actor/user_id/object_name/record_id/old_value/new_value fields, enable.apiMethods get+list)", + "packages/plugins/plugin-audit/src/audit-plugin.ts (nav_audit_logs → sys_audit_log)", + "objectui apps/console/src/pages/system/AuditLogPage.tsx (/api/v1/data/sys_audit_log, $filter/$orderby/$top/$skip, Before/After drawer)", + "objectui apps/console/src/AppContent.tsx (route system/audit-log)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — audit-log browser over sys_audit_log: attributable events, server-side filter, before/after payload drawer, API cross-check, append-only guard", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.owd-save-gate", + "title": "OWD save gate: external ≤ internal on every object write, and a packaged object's OWD can only be tightened at runtime — Studio inline AND server-side", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin (drives the Studio designer and the direct meta PUTs)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase declares externalSharingModel on shipped objects: showcase_announcement (external private ≤ internal public_read) and showcase_account (external public_read ≤ internal public_read_write)", + "a writable/draft object name for the R2 server probe (author via PUT /api/v1/meta/object/qa_owd_probe?mode=draft — no shipped object is mutated)", + "the Studio object designer Settings tab (objectui ObjectSettingsPanel / PackageOwdOverviewPanel), which computes isExternalWider inline" + ], + "knownGaps": [ + "runtime EVALUATION of externalSharingModel against external principals is liveness `planned` (#2696) — this item tests the AUTHORING/save gate (external ≤ internal at write; packaged tighten-only), NOT external-principal enforcement; do not tick an external-principal read/write result", + "R1 (owd_widening_forbidden) is only reachable via the OS_METADATA_WRITABLE=object escape hatch; on a STOCK deploy a runtime meta PUT to a packaged object is refused earlier by the writable-package gate (WRITABLE_PACKAGE_REQUIRED) — the run must record WHICH layer answered the 4xx" + ] + }, + "steps": [ + "boot showcase with the console; admin session", + "Studio inline (browser): open the object designer Settings for a writable/draft object; set sharingModel=private and externalSharingModel=public_read; screenshot — the panel must flag the D11 external-wider violation inline (owd-sharing.ts isExternalWider) and not offer a clean save", + "server R2 reject: PUT /api/v1/meta/object/qa_owd_probe?mode=draft with sharingModel:'private' + externalSharingModel:'public_read' — capture the 4xx + error code", + "server R2 admit: PUT the same draft with externalSharingModel:'private' (≤ private) — capture the 2xx (the gate orders, it does not blanket-deny)", + "packaged tighten (R1): PUT /api/v1/meta/object/showcase_announcement widening sharingModel private→public_read_write (or externalSharingModel private→public_read) — capture the 4xx AND its code (WRITABLE_PACKAGE_REQUIRED on stock; owd_widening_forbidden under OS_METADATA_WRITABLE=object)", + "GET /api/v1/meta/object/showcase_announcement before and after the denied PUT — confirm byte-identical", + "cross-check the shipped declarations: GET /api/v1/meta/object/showcase_account and showcase_announcement — both satisfy external ≤ internal" + ], + "acceptance": [ + { + "clause": "Studio flags external-wider inline: with sharingModel=private + externalSharingModel=public_read the Settings panel shows the D11 violation and does not present a clean save", + "oracle": "screenshot", + "verify": "after the designer settles, the panel surfaces the external-wider warning (owd-sharing.ts isExternalWider / ObjectSettingsPanel externalWider) — a screenshot confirms it before any DOM read", + "evidence": "the designer screenshot" + }, + { + "clause": "server R2 rejects external > internal on ANY object write: the writable-object PUT with externalSharingModel wider than sharingModel answers 4xx (403) code owd_external_wider", + "oracle": "api", + "verify": "PUT /api/v1/meta/object/qa_owd_probe?mode=draft status >=400 and body code 'owd_external_wider' (packages/plugins/plugin-security/src/object-posture-gate.ts R2)", + "evidence": "the PUT trace" + }, + { + "clause": "server R2 admits external ≤ internal: the same object with externalSharingModel no wider than sharingModel saves 2xx — the gate keys on the width ordering, not on the key's presence", + "oracle": "api", + "verify": "PUT with externalSharingModel:'private' answers 2xx and a follow-up GET shows the value", + "evidence": "the PUT trace + read" + }, + { + "clause": "a packaged object's OWD cannot be widened at runtime: the widening PUT on showcase_announcement answers 4xx with a ledgered code — WRITABLE_PACKAGE_REQUIRED on the stock deploy, or owd_widening_forbidden under the OS_METADATA_WRITABLE=object escape hatch — recorded with WHICH layer answered", + "oracle": "api", + "verify": "PUT /api/v1/meta/object/showcase_announcement (widened) status >=400 and code ∈ {WRITABLE_PACKAGE_REQUIRED, owd_widening_forbidden}", + "evidence": "the PUT trace + the code + the answering-layer note" + }, + { + "clause": "the denied widening is inert: GET /api/v1/meta/object/showcase_announcement is byte-identical before and after the refused PUT", + "oracle": "api", + "verify": "the two metadata reads match on sharingModel + externalSharingModel", + "evidence": "the before/after reads" + }, + { + "clause": "the shipped app passes its own gate: showcase_account (external public_read ≤ internal public_read_write) and showcase_announcement (external private ≤ internal public_read) both satisfy external ≤ internal", + "oracle": "api", + "verify": "GET both objects' metadata; externalSharingModel width ≤ sharingModel width for each (OWD_WIDTH private\"} — capture the 422 SHARING_NOT_ENABLED that proves the block (no showcase object opts in)", + "with a publicSharing-enabled fixture object F (redactFields declared): as a member who can see a record r of F, POST /api/v1/share-links {object:F, recordId:r, redactFields?, audience?, password?, expiresAt?} — capture the 201 {token}", + "anon GET /api/v1/share-links//resolve — capture the 200 {record, link, redactFields}; confirm every field in F.publicSharing.redactFields ∪ link.redact_fields is absent from record", + "mint a password-gated link; anon resolve WITHOUT ?password / x-share-password header → 401; with a WRONG password → 401; with the correct password → 200", + "mint audience:'signed_in'; anon resolve → 401 SIGN_IN_REQUIRED; mint audience:'email' and resolve with an email OFF the allowlist → refused", + "DELETE /api/v1/share-links/ (revoke); anon resolve → 410 EXPIRED_OR_REVOKED; mint a short-expiry link and, after it expires, resolve → 410 — the record must never appear", + "delete the shared record r, then anon resolve the still-live token → 410 RECORD_GONE (fail-closed, #5190)", + "GET /api/v1/share-links?object=F&recordId=r as the minter vs a SECOND member — the list returns only the caller's own links" + ], + "acceptance": [ + { + "clause": "mint requires the per-object opt-in: POST /share-links on an object WITHOUT publicSharing.enabled answers 422 SHARING_NOT_ENABLED — link-sharing is opt-in per object (this is exactly why stock showcase blocks the rest of this item)", + "oracle": "api", + "verify": "POST /api/v1/share-links {object: any stock showcase object} → 422 code SHARING_NOT_ENABLED (packages/plugins/plugin-sharing/src/share-link-service.ts getPolicy gate)", + "evidence": "the 422 trace" + }, + { + "clause": "resolve renders the record MINUS redactFields: anon GET /:token/resolve returns 200 with the record, and every field in the object's publicSharing.redactFields ∪ the per-link redact_fields is stripped before it leaves the server", + "oracle": "api", + "verify": "the resolve body's record omits the redaction set; body also carries link + redactFields (packages/runtime/src/domains/share-links.ts applyRedaction)", + "evidence": "the resolve body" + }, + { + "clause": "the password gate holds both sides: no/blank password → 401 NEEDS_PASSWORD; wrong password → 401 WRONG_PASSWORD; correct password → 200 — the token alone is insufficient when a password is set", + "oracle": "api", + "verify": "the three resolve traces with the declared codes (share-links.ts sendErr NEEDS_PASSWORD/WRONG_PASSWORD)", + "evidence": "the three traces" + }, + { + "clause": "audience gating holds: a signed_in-audience link answers 401 SIGN_IN_REQUIRED for an anonymous caller; an email-audience link refuses an email off the allowlist (resolveToken returns null → named refusal, never the record)", + "oracle": "api", + "verify": "signed_in resolve → 401 SIGN_IN_REQUIRED; email resolve with a non-allowlisted email → refused", + "evidence": "the two traces" + }, + { + "clause": "revoke / expiry / record-gone name the refusal, NEVER the record: after DELETE, past expiresAt, or record deletion, resolve answers 410 (EXPIRED_OR_REVOKED / RECORD_GONE) — a dead token must never leak the row (#5190 fail-closed)", + "oracle": "api", + "verify": "post-revoke, post-expiry, and post-record-delete resolves each answer 410 with no record in the body", + "evidence": "the three traces" + }, + { + "clause": "the list is caller-scoped: GET /share-links returns only links created_by the caller; a second member cannot enumerate the minter's tokens even with a guessed recordId", + "oracle": "api", + "verify": "minter's list contains the token; the second member's list for the same object/recordId excludes it (share-links.ts createdBy pin)", + "evidence": "both list bodies" + } + ], + "negative": [ + "a resolve that returns the record after revoke/expiry, that includes a redactField, or that leaks another user's tokens in the list, is a FAIL", + "the /:token/messages branch is ai_conversations-only (Cloud/EE) — a knownGap, not a stock clause; do not tick it on open-framework showcase" + ], + "variants": [ + "audience link_only", + "audience signed_in", + "audience email (allowlist)", + "password-gated", + "redactFields stripped", + "revoked", + "expired", + "record-gone (fail-closed)" + ], + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/runtime/src/domains/share-links.ts (resolve/create/list/revoke; 401 NEEDS_PASSWORD/WRONG_PASSWORD/SIGN_IN_REQUIRED, 410 EXPIRED_OR_REVOKED/RECORD_GONE, applyRedaction)", + "packages/plugins/plugin-sharing/src/share-link-service.ts (getPolicy publicSharing gate → 422 SHARING_NOT_ENABLED, resolveToken audience/password/expiry, #5190 recordStillExists fail-closed, list createdBy scoping)", + "packages/runtime/src/route-ledger.ts (share-links rows incl. public resolve/messages)", + "packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts", + "ADR-0047, ADR-0111 D8, #5190" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — share-link capability tokens (ADR-0047): resolve-minus-redactFields, password/audience gates, fail-closed revoke/expire/record-gone, caller-scoped list; blocked(fixture) because no showcase object opts into publicSharing, messages half split as a Cloud/EE knownGap", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.suggested-binding-loop", + "title": "Suggested audience bindings reconcile, confirm materializes the anchor binding, dismiss removes it; bad states 404/409/400 and non-admins are refused", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "tenant admin (the only principal the surface serves)", + "a plain member (the deny contrast)", + "anonymous (the unconditional-deny contrast)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "sys_audience_binding_suggestion (plugin-security) + the reconcile at boot/list", + "showcase's isDefault permission set showcase_member_default (examples/app-showcase/src/security/permission-sets.ts) — the one declared install-time suggestion" + ], + "knownGaps": [ + "stock showcase AUTO-BINDS its only isDefault set (everyone → showcase_member_default) at boot (examples/app-showcase/src/security/bind-position-sets.ts docblock: the security plugin auto-binds the app's isDefault/fallbackPermissionSet to everyone), so the reconcile marks that suggestion 'confirmed (observed)', NOT pending — there is no PENDING suggestion to confirm/dismiss on stock. The list/reconcile, status-filter, idempotent-409, 404, non-admin-403 and anon-401 clauses run against the confirmed row; the confirm-a-PENDING and dismiss-a-PENDING clause needs a scratch package that suggests a not-auto-bound binding → knownGap until that fixture lands" + ] + }, + "steps": [ + "boot showcase with the console; admin session", + "GET /api/v1/security/suggested-bindings — capture the reconciled list; locate the showcase_member_default → everyone suggestion and its status", + "GET with ?status=confirmed and ?status=pending — confirm the filter narrows and the member_default suggestion reports 'confirmed' (auto-bound at boot)", + "GET with ?status=garbage — capture the 400 naming the accepted values", + "POST /api/v1/security/suggested-bindings//confirm — capture the 409 SUGGESTION_STATE (confirm is not re-runnable on a settled row)", + "POST /api/v1/security/suggested-bindings/does-not-exist/confirm — capture the 404 SUGGESTION_NOT_FOUND", + "as a plain member: GET the list AND POST a confirm — both must be refused 403 (tenant-admin pre-gate)", + "anon (no auth): GET the list — capture the 401 UNAUTHENTICATED", + "(knownGap) PENDING loop: seed/install a package suggesting a not-auto-bound binding so a 'pending' row appears; POST /confirm materializes the anchor binding under the caller's context (verify via /api/v1/security/explain or an access probe); POST /dismiss records the 'no' and drops it from pending; re-list reflects the transition" + ], + "acceptance": [ + { + "clause": "the list reconciles declarations against live bindings: GET /security/suggested-bindings returns showcase's isDefault suggestion, and the reconcile marks it 'confirmed' because everyone → showcase_member_default is already bound at boot (a binding-present suggestion is observed-confirmed, never pending)", + "oracle": "api", + "verify": "the list body carries the member_default suggestion with status 'confirmed' (packages/plugins/plugin-security/src/suggested-audience-bindings.ts syncAudienceBindingSuggestions)", + "evidence": "the reconciled list" + }, + { + "clause": "the status filter is validated: ?status=confirmed|pending|dismissed narrow the set, while ?status=garbage answers 400 naming the accepted values — an unknown filter is not silently an empty list", + "oracle": "api", + "verify": "the three filtered lists + the 400 body (packages/runtime/src/domains/security.ts isSuggestionStatus guard)", + "evidence": "the filtered lists + the 400" + }, + { + "clause": "confirm on a settled row is a 409: POST ...//confirm answers 409 SUGGESTION_STATE — confirm is idempotent-safe, never a double-bind", + "oracle": "api", + "verify": "the confirm trace status 409 code SUGGESTION_STATE (SuggestionStateError)", + "evidence": "the trace" + }, + { + "clause": "a bad suggestion id is a 404: POST .../does-not-exist/confirm|dismiss answers 404 SUGGESTION_NOT_FOUND", + "oracle": "api", + "verify": "the trace status 404 code SUGGESTION_NOT_FOUND (SuggestionNotFoundError)", + "evidence": "the trace" + }, + { + "clause": "the whole surface is tenant-admin-only: a plain member's list AND confirm both answer 403 — the read is as gated as the write (ADR-0066 pre-gate)", + "oracle": "api", + "verify": "member GET list and POST confirm both >=400 (403)", + "evidence": "both member traces" + }, + { + "clause": "anonymous is denied unconditionally: an unauthenticated GET answers 401 UNAUTHENTICATED (this admin seam never honoured a deny opt-out, #2567/#3963)", + "oracle": "api", + "verify": "anon GET /security/suggested-bindings status 401 code UNAUTHENTICATED", + "evidence": "the anon trace" + }, + { + "clause": "(knownGap) confirm materializes a PENDING binding and dismiss removes it: given a fixture seeding a pending suggestion, POST /confirm creates the anchor binding under the CALLER's context (a follow-up /security/explain shows the new grant), and POST /dismiss records the no and drops it from pending", + "oracle": "api", + "verify": "pre/post /security/explain (or access probe) around confirm; the list transitions pending→confirmed and pending→dismissed", + "evidence": "explain before/after + the list transitions" + } + ], + "negative": [ + "a confirm that binds a high-privilege set onto everyone/guest must be refused by the D5/D9 audience-anchor gate (403), never silently accepted", + "a confirm running under the SYSTEM context rather than the caller's is the ADR-0090 D9 violation — the write must carry the admin's identity through the gates", + "the pending-loop clause is fixture-gapped (stock auto-binds the only suggestion) — do not tick confirm/dismiss-a-pending on stock showcase" + ], + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/runtime/src/domains/security.ts (GET list / POST :id/confirm|dismiss; 400 unknown-status, 401 anon, 403/404/409 typed-error mapping)", + "packages/plugins/plugin-security/src/suggested-audience-bindings.ts (syncAudienceBindingSuggestions convergent reconcile; SuggestionNotFoundError 404, SuggestionStateError 409; confirm under caller context)", + "packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts", + "packages/rest/src/rest-route-ledger.ts (security suggested-bindings rows)", + "examples/app-showcase/src/security/bind-position-sets.ts + permission-sets.ts (isDefault auto-bind)", + "ADR-0090 D5/D9/D12, #2567/#3963" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — suggested-binding admin loop (ADR-0090 D5/D9): reconcile/list, status-filter validation, idempotent-409, 404, admin-only + anon-deny; confirm/dismiss-a-pending carried as a knownGap because stock auto-binds its only isDefault suggestion", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.sharing-rule-authoring-ui", + "title": "Authoring a sharing rule in Setup materializes matching grants for exactly the audience; deleting the rule retracts them", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin (authors + deletes the rule in Setup)", + "a member in the rule's audience (gains exactly the matching rows)", + "a member OUTSIDE the audience (the scoping contrast)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the Setup 'Sharing Rules' surface (objectui plugin-sharing sharing-plugin.ts nav_sharing_rules → sys_sharing_rule record form, requiredPermissions manage_platform_settings) OR the POST /api/v1/sharing/rules authoring endpoint", + "a private-OWD object with authorable matching/non-matching rows (showcase_project — health field) and a position to receive the grant (e.g. auditor or a scratch position)" + ], + "knownGaps": [ + "authoring a rule in Setup issues a direct sys_sharing_rule insert (packages/spec/src/security/sharing.zod.ts) — use a criteria DISTINCT from the seeded red-project rules (e.g. record.health == 'green') so this item does not overlap access-security.sharing-rules-widen, which tests the SEEDED rules' enforcement" + ] + }, + "steps": [ + "boot showcase with the console; admin session", + "author a NEW criteria sharing rule via Setup → Sharing Rules (create a sys_sharing_rule record) OR POST /api/v1/sharing/rules: object showcase_project, condition record.health == 'green' (distinct from the seeded red rules), accessLevel 'read', sharedWith {type:'position', value:''} — capture the 2xx", + "as admin: seed a MATCHING probe (a green-health showcase_project owned by someone else) and a NON-MATCHING probe (a red-health project)", + "as the audience persona (holding that position): GET /api/v1/data/showcase_project (list) + GET each probe by id", + "as a member OUTSIDE the audience: the same reads", + "read sys_record_share for the matching project — a row with source 'rule', source_id the new rule name", + "negative-authoring probe: POST /api/v1/sharing/rules with a missing/empty criteria — capture the rejection (defineRule refuses a match-all, #3896)", + "delete the rule via Setup (delete the sys_sharing_rule record) OR DELETE /api/v1/sharing/rules/:idOrName", + "as the audience persona: re-read the matching project by id — refused again; re-read sys_record_share — the rule-sourced grant is gone", + "screenshot the Setup Sharing Rules list before authoring, after authoring, and after delete" + ], + "acceptance": [ + { + "clause": "authoring lands a rule: creating the criteria rule (Setup record form or POST /sharing/rules) answers 2xx and the rule reads back via GET /api/v1/sharing/rules/:idOrName", + "oracle": "api", + "verify": "the create response 2xx + the getRule read of the same idOrName", + "evidence": "create + read traces" + }, + { + "clause": "a match-all criteria is refused at authoring: a rule with a missing/empty criteria answers 400 (defineRule refuses match-all, #3896) — a typo'd predicate cannot silently share every record", + "oracle": "api", + "verify": "POST /sharing/rules with empty criteria → 400 VALIDATION_FAILED naming the field", + "evidence": "the rejection" + }, + { + "clause": "the rule materializes grants for MATCHING rows only: a sys_record_share (source 'rule', source_id the rule name) exists for the matching green project and none for the non-matching red project", + "oracle": "api", + "verify": "system-context sys_record_share reads for both probes", + "evidence": "the two share reads" + }, + { + "clause": "the audience persona gains exactly the matching rows, both sides: the audience persona reads the matching project (list + by-id) but not the non-matching; a member OUTSIDE the audience reads neither — widening is scoped", + "oracle": "api", + "verify": "audience: matching by-id 200 + present in list, non-matching by-id non-200; outsider: both by-id non-200", + "evidence": "the four+ reads" + }, + { + "clause": "delete retracts: deleting the rule removes its materialized sys_record_share grants and the audience persona's next by-id read of the matching project is refused again", + "oracle": "api", + "verify": "post-delete: sys_record_share for the matching project has no rule-sourced row; audience by-id GET non-200", + "evidence": "post-delete share read + persona read" + }, + { + "clause": "the Setup surface reflects the rule lifecycle: after authoring the Sharing Rules list shows the new rule; after delete it is gone — the UI renders the same rows the API serves", + "oracle": "screenshot", + "verify": "before/after/after-delete screenshots of the Setup Sharing Rules list", + "evidence": "the three screenshots" + } + ], + "negative": [ + "a rule that shares the NON-matching (red) project, or grants that survive the rule's deletion (orphaned sys_record_share), is a FAIL", + "do not overlap access-security.sharing-rules-widen: that item verifies the SEEDED rules' enforcement — this item authors a DISTINCT criteria in-run and verifies authoring + retraction" + ], + "traps": [ + "wrong-persona", + "hydration-race" + ], + "source": [ + "packages/rest/src/rest-server.ts (registerSharingRuleEndpoints ~L7345-7494: list/create/get/delete)", + "packages/spec/src/security/sharing.zod.ts (criteria rule authoring + match-all refusal #3896)", + "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts + sys-record-share.object.ts", + "objectui packages/plugins/plugin-sharing/src/sharing-plugin.ts (nav_sharing_rules Setup nav → sys_sharing_rule)", + "cross-ref access-security.sharing-rules-widen (seeded-rule enforcement), ADR-0058 D3, ADR-0111 D6" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — sharing-rule AUTHORING loop (Setup create → materialized matching grants → delete retracts), distinct from sharing-rules-widen which tests the seeded rules' enforcement", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.permission-matrix-edit-loop", + "title": "Editing the permission matrix and publishing flips an affected persona's live API access; revoke flips it back; assign/unassign moves access", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin (edits the matrix + publishes)", + "member M (holds the edited set — the persona whose API access flips)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a WRITABLE (draft/app-local) permission set S — authored in-run or an unlocked set (a locked package set is the readonly-package-locks-studio case, not this one)", + "member M granted S via a sys_user_permission_set row", + "the console Permission Matrix editor (objectui PermissionMatrixEditor.tsx, type=permission → client.save() → PUT /api/v1/meta/permission/, then publish)" + ] + }, + "steps": [ + "boot showcase with the console; admin session; provision member M granted a writable set S that WITHHOLDS a verb on an object (e.g. delete:false on showcase_task)", + "baseline: as M, DELETE /api/v1/data/showcase_task/ — must be refused 403 (the withheld verb)", + "in the console Permission Matrix editor for S, flip delete ON for showcase_task; save (draft) then publish — capture PUT /api/v1/meta/permission/S and POST /api/v1/meta/permission/S/publish", + "as M: DELETE /api/v1/data/showcase_task/ — must now be 2xx (access flipped)", + "flip delete back OFF in the matrix; publish; as M: DELETE again — must be refused 403", + "field-level half: flip an FLS bit (a showcase_project field editable:false→true) in the lower matrix; publish; as M PATCH that field — the write flips accordingly", + "assign/unassign: grant M a SECOND permission set (insert sys_user_permission_set) — M gains its verbs; remove the row — the access retracts", + "re-open the editor for S and read the checkbox state for the flipped verb" + ], + "acceptance": [ + { + "clause": "baseline withheld verb is denied: before the edit, M's DELETE on the object answers 403 PERMISSION_DENIED (S withholds it)", + "oracle": "api", + "verify": "DELETE /api/v1/data/showcase_task/ as M → 403 PERMISSION_DENIED", + "evidence": "the baseline trace" + }, + { + "clause": "the matrix edit saves and publishes: flipping the verb in the console and publishing answers 2xx on PUT /api/v1/meta/permission/S and the publish call, and the published set carries the new verb", + "oracle": "api", + "verify": "PUT + POST .../publish both 2xx; GET /api/v1/meta/permission/S (published) shows delete:true on showcase_task", + "evidence": "PUT + publish + GET" + }, + { + "clause": "access flips ON after publish: M's identical DELETE now answers 2xx and the row is gone — the persona's live API access tracks the PUBLISHED matrix", + "oracle": "api", + "verify": "DELETE as M → 2xx + a follow-up read shows the row absent", + "evidence": "trace + re-read" + }, + { + "clause": "revoke flips it back: flipping the verb OFF and republishing returns M's DELETE to 403 — the edit loop is reversible", + "oracle": "api", + "verify": "post-republish DELETE as M → 403 PERMISSION_DENIED", + "evidence": "the trace" + }, + { + "clause": "assign/unassign moves access: granting M a second permission set widens M's API access to that set's verbs; removing the sys_user_permission_set row retracts it", + "oracle": "api", + "verify": "before/after traces of a verb the second set grants — 2xx while assigned, >=400 after unassign", + "evidence": "the before/after traces" + }, + { + "clause": "the editor reflects the published state: re-opening the matrix shows the flipped verb's checkbox in its published state — the editor renders the published set, not a stale draft", + "oracle": "dom", + "verify": "after a screenshot confirms the editor rendered, the verb's checkbox matches the last published value", + "evidence": "screenshot + checkbox DOM read" + } + ], + "negative": [ + "an edit that repaints the matrix but whose PUT/publish never lands (M's access unchanged) is a FAIL — the network + M's live access are the oracles, not the checkbox paint", + "enforcement flipping on the DRAFT alone (before publish) is wrong: access must key on the PUBLISHED set", + "cross-ref: enforcement correctness is access-security.crud-permission-matrix and the read-only-package lock is access-security.readonly-package-locks-studio — this item is the WRITE/edit loop only" + ], + "variants": [ + "object-verb flip (CRUD)", + "field-level R/W flip (FLS)", + "permission-set assign/unassign" + ], + "traps": [ + "stale-console-bundle", + "hydration-race", + "wrong-persona" + ], + "source": [ + "objectui packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx (type=permission editor; client.save → PUT /api/v1/meta/permission/; object CRUD+VAMA+lifecycle + field R/W)", + "packages/spec/src/security/permission.zod.ts (PermissionSetSchema)", + "packages/rest/src/rest-route-ledger.ts (PUT /api/v1/meta/:type/:name saveItem; POST .../publish publishItem)", + "examples/app-showcase/src/security/permission-sets.ts", + "cross-ref access-security.crud-permission-matrix + access-security.readonly-package-locks-studio, ADR-0090 D1/D5, ADR-0033" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — permission-matrix WRITE/edit loop: flip a verb in the console matrix → publish → affected persona's live API access flips, revoke flips back, assign/unassign moves access (enforcement + lock covered by sibling items)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "access-security.capability-declaration-lifecycle", + "title": "A package-declared capability bootstraps into sys_capability and resolves across the grant/require three-way (ADR-0066); platform names cannot be shadowed", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin", + "a member whose permission set GRANTS the capability", + "a member whose set does NOT" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped capability declaration examples/app-showcase/src/security/capabilities.ts (showcase.export_data, org-scoped, defineCapability) — the DEFINE side of ADR-0066", + "OpsPermissionSet granting it via systemPermissions (examples/app-showcase/src/security/permission-sets.ts) — the GRANT side" + ], + "knownGaps": [ + "no shipped resource REQUIREs showcase.export_data yet (the source note calls it a 'future export endpoint/action') — the require-side enforcement clause needs a scratch resource carrying requiredPermissions:['showcase.export_data'], or is recorded as a knownGap on stock seeds" + ] + }, + "variants": [ + "DEFINE — capability metadata authored via defineCapability, bootstrapped to a sys_capability row", + "GRANT — a permission set's systemPermissions carries the capability name", + "REQUIRE — a resource's requiredPermissions lists it; denied unless a granted set carries it", + "shadow-refusal — a declaration whose name collides with PLATFORM_CAPABILITY_NAMES is refused loudly" + ], + "steps": [ + "boot showcase isolated; confirm the declared capability bootstrapped: GET the sys_capability row for showcase.export_data (bootstrap-declared-capabilities.ts upsertPackageCapability writes name/label/description/scope)", + "read its fields — label falls back to humanize(name), description to 'Capability .' when absent (capabilityRowFields)", + "as the GRANTED member (holding OpsPermissionSet) and as a NON-granted member, exercise a resource that REQUIREs the capability (author a scratch resource with requiredPermissions:['showcase.export_data'] if none ships — see knownGaps): granted passes, non-granted is denied 403", + "author a scratch package declaring a capability whose name is in PLATFORM_CAPABILITY_NAMES; boot/validate", + "author a scratch capability with a malformed/unvalidated shape and confirm the write door validates it before it reaches the sys_capability upsert (the #5961 concern: an unvalidated row lands directly in the authorization namespace)" + ], + "acceptance": [ + { + "clause": "the package-declared capability materializes as a sys_capability row at boot with its authored identity (name is the upsert key; label/description apply their documented fallbacks)", + "oracle": "api", + "verify": "the sys_capability read for showcase.export_data matches the declaration; label/description fallbacks hold when omitted", + "evidence": "the row read + the declaration source" + }, + { + "clause": "the three-way resolves by NAME: a resource requiring the capability is denied for a member whose granted permission sets do not carry it, and permitted for one that does — both sides", + "oracle": "api", + "verify": "403 for the non-granted member, 2xx for the OpsPermissionSet holder, on a resource carrying requiredPermissions:['showcase.export_data']", + "evidence": "the two responses (scratch resource if none ships — record which)" + }, + { + "clause": "a declaration whose name shadows a curated PLATFORM_CAPABILITY_NAME is refused LOUDLY — a package cannot hijack a platform-owned capability name", + "oracle": "build", + "verify": "the shadow declaration is rejected at authoring/boot with a located error naming the collision (bootstrap-declared-capabilities.ts guard)", + "evidence": "the refusal text" + }, + { + "clause": "the capability write door validates before the upsert — an unvalidated capability row must never land directly in the authorization namespace (#5961)", + "oracle": "build", + "verify": "a malformed capability shape is rejected at parse/validate, not silently upserted into sys_capability", + "evidence": "the rejection" + } + ], + "negative": [ + "a shadow declaration silently accepted (overwriting a platform capability) is a FAIL; an unvalidated capability row reaching sys_capability is the #5961 authz-namespace-injection FAIL" + ], + "traps": [ + "wrong-persona", + "stale-dist" + ], + "source": [ + "packages/spec/liveness/capability.json (ADR-0066 D1, #5961 — the kind's own liveness ledger)", + "packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts (upsertPackageCapability, capabilityRowFields, PLATFORM_CAPABILITY_NAMES guard)", + "packages/lint/src/validate-capability-references.ts (authoring lint known-name set)", + "examples/app-showcase/src/security/{capabilities.ts,permission-sets.ts} (DEFINE + GRANT fixtures)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — the coverage ratchet caught `capability` as a NEW metadata kind (#5961 landed on main); authored the declaration→bootstrap→grant/require lifecycle + shadow-refusal + the #5961 authz-namespace validation concern", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json new file mode 100644 index 0000000000..2142df55fb --- /dev/null +++ b/docs/qa/platform-checklist/areas/ai.json @@ -0,0 +1,580 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md. NOTE: AI is deliberately absent from the showcase app (ADR-0063 — platform-owned), so most items here carry explicit fixture requirements instead of assuming showcase seeds. BOUNDARY: the in-product agent runtime (`@objectstack/service-ai`) is Cloud/EE (cloud repo) — the OPEN framework serves the metadata surface, the MCP surface (packages/mcp), and honest 501s on /ai/** (route-ledger '* /ai/**' row); items below only assert what the open framework actually runs.", + "area": "ai", + "title": "AI — agents, tools, skills, MCP", + "items": [ + { + "id": "ai.agent-tool-skill-metadata-roundtrip", + "title": "agent / tool / skill metadata kinds author, persist and list over the meta surface — retired keys reject with prescriptions, the closed agent kind has no runtime write door", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "personas": ["admin"], + "fixtures": { + "app": "any", + "requires": [ + "a writable package to author into (AI artifacts are not seeded by the showcase — ADR-0063); files land under the registered patterns *.agent.ts / *.tool.ts / *.skill.ts (packages/spec/src/kernel/metadata-plugin.zod.ts)", + "an admin session for the meta reads and a fresh unauthenticated client for the anonymous-deny half" + ], + "knownGaps": ["no stock example app ships AI seeds; a minimal agent+tool+skill fixture config is needed for the full round-trip"] + }, + "variants": [ + "agent (AgentSchema requires name/label/role/instructions; surface defaults 'ask'; strictObject with aliases prompt→instructions, capabilities→skills …)", + "tool (ToolSchema .strict() requires name/label/description/parameters)", + "skill (SkillSchema requires name/label/tools; instructions optional; surface defaults 'ask')", + "tombstone probe: tool.permissions (removed spec 17, #3896)", + "tombstone probe: tool.active (removed spec 17, #3896)", + "tombstone probe: tool.category (removed spec 17, #3896)", + "tombstone probe: tool.builtIn (removed spec 17, #3896)", + "tombstone probe: tool.requiresConfirmation (removed 16.x, #3715 / ADR-0033 §2)", + "tombstone probe: agent.tools (removed spec 17, #3894 — retiredKey)", + "tombstone probe: agent.knowledge (removed spec 17, #3896 — retiredKey)", + "tombstone probe: skill.triggerPhrases (removed spec 17, #3896 — retiredKey)", + "guidance probe: skill.permissions / skill.trigger (never keys — guidance map, #5013)" + ], + "steps": [ + "read the three Zod shapes FIRST and pin the requireds: AgentSchema needs name/label/role/instructions (packages/spec/src/ai/agent.zod.ts), ToolSchema needs name/label/description/parameters and is .strict() (tool.zod.ts), SkillSchema needs name/label/tools (skill.zod.ts) — if these have drifted, revise this item before running", + "author one valid item per kind in the writable package: defineAgent({ name: 'qa_probe_agent', label, role, instructions, skills: ['qa_probe_skill'] }), defineTool({ name: 'qa_probe_tool', label, description, parameters: { type: 'object', properties: {} } }), defineSkill({ name: 'qa_probe_skill', label, tools: ['qa_probe_tool'], instructions: 'probe instructions' }); build", + "author the tombstone probes from `variants` (one broken copy per retired key, e.g. tool with `permissions: []`, agent with `tools: [{...}]`, skill with `triggerPhrases: ['x']`); capture each build/parse error text verbatim", + "boot; sign in as admin; GET /api/v1/meta (meta.getTypes) and GET /api/v1/meta/types — capture which kinds are registered", + "GET /api/v1/meta/agent, /api/v1/meta/tool, /api/v1/meta/skill (route ledger: meta.getItems) and locate the three authored probes", + "GET /api/v1/meta/skill/qa_probe_skill (meta.getItem) and field-diff against the authored source, noting applied defaults (surface:'ask', active:true)", + "PUT /api/v1/meta/skill/qa_probe_skill?mode=draft with an edited copy (skill/tool are allowRuntimeCreate:true); then attempt the same runtime save for the agent kind and capture the outcome (agent is allowRuntimeCreate:false + allowOrgOverride:false — ADR-0063 §2, metadata-plugin.zod.ts)", + "repeat one meta list unauthenticated; capture status", + "POST /api/v1/ai/tools/qa_probe_tool/execute (any payload) on the open-framework boot and capture the 501 — evidence for the read-only-projection clause, NOT an expected execution" + ], + "acceptance": [ + { + "clause": "each kind's valid probe parses at build and each tombstone probe is rejected LOUDLY with the retired key NAMED and its prescription attached — never silently stripped", + "oracle": "build", + "verify": "tool.permissions error contains 'promised a capability gate on tool invocation that nothing ever enforced' (TOOL_RETIRED_KEY_GUIDANCE, tool.zod.ts); agent.tools error prescribes `skills` + ADR-0064 + 'os migrate meta --from 16' (retiredKey, agent.zod.ts); skill.triggerPhrases error says phrases were never matched and routes intent to triggerConditions (skill.zod.ts); the valid trio builds clean", + "evidence": "per-variant error texts keyed by variant + the clean build output" + }, + { + "clause": "the metadata registry serves all three kinds under their SINGULAR names (agent, tool, skill — AGENTS.md PD#3), with schemas resolved (BUILTIN_METADATA_TYPE_SCHEMAS)", + "oracle": "api", + "verify": "GET /api/v1/meta (and /meta/types) lists agent, tool, skill; no plural variants", + "evidence": "the types responses" + }, + { + "clause": "authored items list and read back field-identical over /api/v1/meta/[/], with schema defaults applied (agent.surface/skill.surface 'ask', active true)", + "oracle": "api", + "verify": "list + get round-trip for all three probes; field-diff shows every authored value plus only the documented defaults", + "evidence": "list/get responses + diff" + }, + { + "clause": "the runtime write door matches the declared registry posture — BOTH sides: PUT ?mode=draft succeeds for skill (and tool), while the agent kind has NO governed runtime write path (allowRuntimeCreate:false; 'for agents, the code is the record', metadata-plugin.zod.ts — migrateStoredMetadata reports agent rows 'skipped' by design)", + "oracle": "api", + "verify": "skill draft PUT returns 200 and the draft is readable; the agent runtime save is refused/unsupported (capture the actual status), and that refusal is recorded as CORRECT, not filed as a bug", + "evidence": "the two PUT responses" + }, + { + "clause": "anonymous-deny holds on the meta surface for AI kinds", + "oracle": "api", + "verify": "unauthenticated GET /api/v1/meta/agent → 401", + "evidence": "the 401 response" + }, + { + "clause": "tool metadata is a READ-ONLY PROJECTION, not an execution entry point — authoring qa_probe_tool creates no runnable tool in the open framework (ToolSchema's own describe: no handler field; runtime executes a separately-registered AIToolDefinition, cloud-side)", + "oracle": "api", + "verify": "POST /api/v1/ai/tools/qa_probe_tool/execute answers the 501 capability-unavailable envelope (see ai.open-edition-honest-degradation); the run must NOT tick 'authored ⇒ callable'", + "evidence": "the 501 response" + } + ], + "negative": [ + "any tombstone probe accepted with a clean build/parse is a FAIL — the silent strip is the exact #3896/#3820 regression these retiredKey/strict gates exist to prevent", + "an unauthenticated request to a meta list route succeeding is a FAIL (anonymous-deny must hold on the AI family too)" + ], + "traps": ["stale-dist"], + "source": [ + "packages/spec/src/ai/agent.zod.ts (requireds, aliases, retiredKey tools/knowledge, visibility/tenantId guidance)", + "packages/spec/src/ai/tool.zod.ts (.strict() + TOOL_RETIRED_KEY_GUIDANCE; READ-ONLY PROJECTION note)", + "packages/spec/src/ai/skill.zod.ts (requireds, retiredKey triggerPhrases, permissions/trigger guidance #5013)", + "packages/spec/src/kernel/metadata-type-schemas.ts (agent/tool/skill registered with schemas)", + "packages/spec/src/kernel/metadata-plugin.zod.ts (registry rows: agent allowRuntimeCreate:false ADR-0063 §2; tool/skill true; file patterns)", + "packages/runtime/src/route-ledger.ts (GET /meta, GET /meta/types, GET/PUT /meta/:type/:name)", + "packages/spec/liveness/agent.json + tool.json + skill.json (which props are live, and that live evidence sits in cloud)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — closes the AI hole in capability coverage (no area covered agent/tool/skill kinds)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "ai.mcp-http-surface", + "title": "MCP HTTP transport (/mcp) and the public /mcp/skill endpoint gate exactly as documented: 404 when opted out, 501 when unimplemented, 401 anonymous, 403 scopeless-OAuth, full tool list when keyed", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "fixtures": { + "app": "any", + "requires": [ + "one boot with the MCP HTTP surface at its DEFAULT (on — served unless OS_MCP_SERVER_ENABLED=false) and one boot with OS_MCP_SERVER_ENABLED=false (the #3358 sweep hit an off config and misread the refusal)", + "an osk_ API key minted via POST /api/v1/keys (route ledger keys.create) for the authenticated half" + ] + }, + "variants": [ + "OAuth scope data:read → read-family tools: list_objects, describe_object, query_records, get_record (packages/spec/src/ai/mcp.zod.ts MCP_OAUTH_SCOPE_DATA_READ)", + "OAuth scope data:write → write-family tools: create_record, update_record, delete_record", + "OAuth scope actions:execute → list_actions, run_action" + ], + "steps": [ + "boot with OS_MCP_SERVER_ENABLED=false; GET /api/v1/mcp/skill and POST /api/v1/mcp; record status + body of both", + "boot with the default-on config; GET /api/v1/mcp/skill UNAUTHENTICATED; capture status, content-type, content-disposition and the markdown head", + "POST /api/v1/mcp/skill (wrong method); capture the 405 and its Allow header", + "POST /api/v1/mcp with NO credentials (an MCP initialize body); capture the 401 body and any WWW-Authenticate header", + "open an MCP Streamable HTTP session against /api/v1/mcp with the osk_ key: initialize, then tools/list; record the full tool-name set", + "if the OAuth track is live in the environment, request /mcp with an OAuth token carrying NONE of the three MCP scopes; capture the 403 + WWW-Authenticate error=\"insufficient_scope\"", + "capture the dispatcher-vs-hono distinction: the disabled boot's 404 comes from the gate (surface not advertised), never a hang or 500" + ], + "acceptance": [ + { + "clause": "the opted-out boot answers 404 'MCP server is not enabled for this environment' on BOTH /mcp and /mcp/skill — a deliberate un-advertised surface, NOT the 501 (which means route mounted but MCP plugin missing: 'MCP server is not available')", + "oracle": "api", + "verify": "status + body of both routes on the disabled boot match packages/runtime/src/domains/mcp.ts (isMcpServerEnabled → 404; unresolvable mcp service → 501); record WHICH of the two refusals was observed", + "evidence": "the two responses" + }, + { + "clause": "GET /mcp/skill serves the public SKILL.md unauthenticated on the enabled boot: 200, text/markdown; charset=utf-8, content-disposition inline; filename=\"SKILL.md\", cache-control no-store", + "oracle": "api", + "verify": "headers + markdown body head; the Connect section carries this environment's own /api/v1/mcp URL (ADR-0036 Amendment C)", + "evidence": "the response head + first lines of the markdown" + }, + { + "clause": "a non-GET on /mcp/skill answers 405 with an Allow: GET header and the standard error envelope", + "oracle": "api", + "verify": "POST /mcp/skill → 405, Allow: GET, body message 'Method not allowed — use GET'", + "evidence": "the 405 response" + }, + { + "clause": "an anonymous /mcp request is denied 401 BEFORE any tool runs — 'Unauthorized: a valid API key is required' (or the OAuth wording plus a WWW-Authenticate resource_metadata pointer when the OAuth track is live, RFC 9728 §5.1)", + "oracle": "api", + "verify": "status/body/headers of the anonymous POST against domains/mcp.ts's two 401 forms", + "evidence": "the 401 response incl. headers" + }, + { + "clause": "a keyed MCP client completes initialize + tools/list, and the tool set is the documented principal-bound surface: list_objects, describe_object, query_records, aggregate_records, get_record, create_record, update_record, delete_record, list_actions, run_action, validate_expression", + "oracle": "network", + "verify": "tools/list names against packages/mcp/src/mcp-http-tools.ts registrations (aggregate_records may be absent only when the bridge cannot route it — graceful degradation, record which)", + "evidence": "the session trace + tool-name list" + }, + { + "clause": "an OAuth token granting none of the MCP scopes is refused 403 up front with insufficient_scope naming all three scopes (data:read, data:write, actions:execute); a partial grant narrows the tool list per the variants matrix (#2698, ADR-0090 D10)", + "oracle": "api", + "verify": "403 body 'Forbidden: the access token grants none of the MCP scopes …' + WWW-Authenticate error=\"insufficient_scope\" scope=\"data:read data:write actions:execute\"; for a data:read-only token, tools/list carries the read family only", + "evidence": "the 403 + the narrowed tools/list" + } + ], + "negative": [ + "the enabled boot must still reject tool CALLS that need auth when the session carries none — transport up ≠ authz open; a tools/call succeeding anonymously is a FAIL", + "reading the 501 'MCP server is not available' as 'MCP is disabled' (or vice versa) is a recording error — the two refusals separate config-off from implementation-missing and the run record must name which one it saw" + ], + "traps": ["dispatcher-vs-hono-route"], + "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts" }, + "source": [ + "packages/runtime/src/domains/mcp.ts (404/501/401/403/405 branches, exact messages)", + "packages/spec/src/ai/mcp.zod.ts (MCP_OAUTH_SCOPES + scopesToAgentPermissionSets, ADR-0090 D10)", + "packages/mcp/src/mcp-http-tools.ts (registered tool set; scope → tool-family narrowing)", + "packages/mcp/src/plugin.ts (OS_MCP_SERVER_ENABLED default-on semantics)", + "packages/runtime/src/route-ledger.ts ('* /mcp/**', 'GET /mcp/skill')", + "packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts (#3167 self-connection pin)", + "#3358 §9 (the swept config was off; both sides are now explicit)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — the #3358 sweep could not drive MCP because the config was off; both sides are now explicit", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "ai.mcp-stdio-fail-closed", + "title": "MCP stdio auto-start fails closed without OS_MCP_STDIO_API_KEY; with a member-bound key, reads AND aggregates honor RLS/FLS and revocation bites on the next call", + "since": "v16", + "status": "active", + "revision": 3, + "priority": "P0", + "surface": "cli", + "personas": ["restricted member API key (for the RLS half)", "admin API key (for the full-row comparison)"], + "fixtures": { + "app": "any", + "requires": [ + "an OWD-private object with rows owned by two different users (the showcase task/invoice seeds qualify when run against the showcase)", + "two osk_ API keys: one bound to a restricted member, one to an admin (Setup → Connect an Agent, or POST /api/v1/keys)", + "for the aggregate half: a field on that object the restricted member cannot read (the same FLS-masked field the row/field clause relies on), plus at least one readable field to group a count by" + ], + "knownGaps": [ + "aggregate_records is registered on the MCP transport only when the runtime routes aggregation through the ObjectQL engine (mcp-http-tools.ts registers it IFF the bridge exposes aggregate — graceful degradation, the same contract as the action bridge). The showcase runtime does route it through the engine (packages/runtime/src/domains/mcp.ts). If tools/list lacks aggregate_records on this boot, record the aggregate clauses blocked(dependency), not a fail" + ] + }, + "steps": [ + "start the app with the stdio transport enabled (OS_MCP_STDIO_ENABLED=true, or the plugin autoStart option) and OS_MCP_STDIO_API_KEY UNSET; capture the startup failure", + "start again with OS_MCP_STDIO_API_KEY=osk_unknown (a key that resolves to nothing); capture that failure", + "start with the restricted member's valid key; capture the principal-bound startup log line", + "over MCP stdio, query_records the OWD-private object and get_record a row owned by the OTHER user; record row sets and field sets", + "run the same two reads over REST as the same member (Bearer the same key) and as the admin key; compare", + "aggregate parity: over MCP stdio as the member, aggregate_records the OWD-private object with a count grouped by a readable field ({ aggregations: [{ function: 'count', alias: 'n' }], groupBy: [''] }); run the SAME aggregate over REST as the member and as the admin; compare the per-group counts", + "FLS aggregate fail-closed: over MCP stdio as the member, aggregate_records with an aggregation (or a groupBy) naming the field the member cannot read (the masked field above); capture the refusal and confirm no numeric result is returned", + "revoke the member key while the stdio session is up; issue one more read; capture the refusal", + "start once more with stdio NOT enabled and no key set; confirm a clean boot (the key is only demanded when stdio is on)" + ], + "acceptance": [ + { + "clause": "enabled-but-keyless auto-start REFUSES to serve, naming the missing var and the remedy: '[MCP] The stdio transport is enabled (OS_MCP_STDIO_ENABLED / autoStart) but OS_MCP_STDIO_API_KEY is not set. … Refusing to start an unscoped stdio server (ADR-0101).'", + "oracle": "log", + "verify": "plugin.start throws /OS_MCP_STDIO_API_KEY/ (packages/mcp/src/plugin.ts); the process does not serve", + "evidence": "the thrown message / startup log" + }, + { + "clause": "an unknown/revoked/expired key is rejected up front — 'OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). Refusing to start stdio (ADR-0101).' — never a fall-back to an anonymous-but-serving session", + "oracle": "log", + "verify": "startup with osk_unknown throws /did not resolve to a valid identity/", + "evidence": "the thrown message" + }, + { + "clause": "a valid key binds the transport to a REAL principal and says so: '[MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity (RLS/FLS/tenant applied)'", + "oracle": "log", + "verify": "the startup line carries the member's userId", + "evidence": "the log line" + }, + { + "clause": "member-keyed MCP reads return exactly the member's REST row/field sets (RLS rows hidden, FLS fields masked), while the admin key sees the full set — both sides of the gate", + "oracle": "api", + "verify": "row-set + field-set comparison MCP-vs-REST per principal; the OTHER user's row absent for the member on both surfaces, present for admin", + "evidence": "the four compared reads" + }, + { + "clause": "member-keyed MCP aggregate_records equals THAT member's REST aggregate, NOT the admin's — the group counts reconcile to the member's own RLS-scoped rows (the other user's rows are excluded from the member's counts on both surfaces; the admin's counts are strictly higher wherever the member's RLS hides rows). MCP aggregation routes through the ObjectQL engine read path so RLS always runs; the raw per-env driver is deliberately not passed (§G1 #2976)", + "oracle": "api", + "verify": "per-group count comparison MCP-vs-REST for the member matches; member counts < admin counts on the buckets the member cannot fully see (packages/runtime/src/domains/mcp.ts aggregate → callData('aggregate') resolves the engine so the security middleware runs)", + "evidence": "the member MCP aggregate, the member REST aggregate, and the admin REST aggregate" + }, + { + "clause": "an aggregate whose input or groupBy names an FLS-unreadable field fails CLOSED — the FLS aggregate-INPUT gate rejects before any statistic is computed ('Field read denied', details.forbiddenFields: []); a masked field's sum/count_distinct never leaks through an alias, because result masking cannot run on aggregate output rows (they carry only aliases) — so the leak is stopped on the input (#2976)", + "oracle": "api", + "verify": "aggregate_records over the masked field errors with the field-read-denied refusal and returns no numeric result; the same aggregate over a READABLE field succeeds (and count(*) needs no field) — the gate targets the field, not the operation", + "evidence": "the refusal + a control aggregate over a readable field" + }, + { + "clause": "revocation is honored on the NEXT read (the identity is re-resolved per call), failing with 'MCP stdio identity is no longer valid (key revoked or expired)'", + "oracle": "log", + "verify": "the post-revocation read errors with that message instead of serving stale authority", + "evidence": "the refusal" + }, + { + "clause": "stdio disabled ⇒ no key demanded: the boot completes and logs '[MCP] Transport not auto-started …' (the HTTP surface is served per-request regardless)", + "oracle": "log", + "verify": "clean start with neither OS_MCP_STDIO_ENABLED nor a key", + "evidence": "the info line" + } + ], + "negative": [ + "an invalid/revoked key falling back to an anonymous-but-serving session is THE fail this item exists for — any served read without a resolved principal is a P0 FAIL", + "a member-keyed MCP read returning rows the same member's REST read hides is a FAIL (MCP must not be a side door around RLS)", + "an MCP aggregate count that equals the ADMIN's total (i.e. counts rows the same member's REST read hides) is a P0 FAIL — an RLS-bypassing count is the sys_attachment-total-leak class (#2976): a bypass leaks row existence and statistics even when the individual rows stay hidden", + "an aggregate over an FLS-masked field returning a number instead of failing closed is a FAIL — the input gate is the only place the leak is stopped, since output masking cannot see through an alias" + ], + "traps": ["wrong-persona"], + "automated": { "kind": "unit", "ref": "packages/mcp/src/__tests__/plugin.test.ts" }, + "source": [ + "packages/mcp/src/plugin.ts (the three refusal strings + per-call re-resolution, ADR-0101)", + "packages/mcp/src/__tests__/plugin.test.ts ('stdio principal admission — fail-closed')", + "packages/runtime/src/domains/mcp.ts (the aggregate bridge routes through callData('aggregate') → the ObjectQL engine so RLS + the FLS aggregate gate always run; the raw driver is deliberately NOT passed)", + "packages/mcp/src/mcp-http-tools.ts (aggregate_records registration: 'Runs under the caller's permissions, row-level security and field-level security'; registered only when the bridge exposes aggregate)", + "packages/plugins/plugin-security/src/security-plugin.test.ts (FLS aggregate-INPUT gate: aggregating OR grouping-by an unreadable field is denied fail-closed with details.forbiddenFields; readable fields aggregate fine)", + "#3358 §9 (verified PASS: fail-closed guard present, reads honor RLS/FLS)", + "docs/plans/release-15.1-test-plan.md §G1 (#2976 — aggregate_records走 ENGINE 读路径; RLS/tenant 与 find 一致; FLS 输入门 fail-closed)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358 §9", "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-08", "change": "clause-extension (§G1 #2976): member-keyed aggregate_records reconciles to the member's OWN REST aggregate (RLS parity, not the admin's), and an aggregate over an FLS-masked field fails closed on the input gate — the RLS-bypassing count is the sys_attachment-total-leak class", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "ai.mcp-run-action-exposure-gate", + "title": "MCP run_action requires ai.exposed (fail-closed with the prescription), filters list_actions to exposed+permitted, refuses system objects, and logs the trusted-elevation audit line", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase's ONE ai-exposed action: showcase_portfolio_snapshot (global script action, ai: { exposed: true }, examples/app-showcase/src/ui/actions/index.ts) — the exposed side", + "any showcase action WITHOUT an ai block (e.g. showcase_mark_done on showcase_task) — the undeclared side", + "MCP reachable with an osk_ key (see ai.mcp-http-surface)" + ] + }, + "steps": [ + "over MCP, call list_actions (no objectName, then objectName: 'showcase_task'); record which actions appear", + "call run_action on the UNDECLARED action (actionName: 'showcase_mark_done', a real recordId); capture the refusal", + "verify the refusal left zero side effects: read the task over REST — status unchanged", + "call run_action on the exposed action (actionName: 'showcase_portfolio_snapshot'); capture the result payload ({ ok, scope: 'global', accounts, projects, invoices })", + "capture the server log for the successful call", + "call run_action against an action on a system object (any sys_* action name) and capture that refusal", + "cross-check the exposed action's counts against direct REST counts of showcase_account / showcase_project / showcase_invoice" + ], + "acceptance": [ + { + "clause": "the undeclared action is rejected fail-closed with the exact prescription: \"Action 'showcase_mark_done' on 'showcase_task' is not exposed to AI — the app author must opt it in with `ai: { exposed: true, description: … }`\" (declared ≠ exposed is the gate, PD#10 discipline)", + "oracle": "api", + "verify": "the MCP error text matches actionAiExposureError (packages/runtime/src/action-execution.ts); the REST re-read shows the record untouched", + "evidence": "the refusal + the unchanged-record read" + }, + { + "clause": "list_actions returns ONLY actions that are both ai-exposed and permitted to the caller — showcase_portfolio_snapshot present, showcase_mark_done absent", + "oracle": "api", + "verify": "the list_actions payload against the fixture's declared ai blocks (mcp-http-tools.ts: 'actions that are BOTH declared ai-exposed and that the caller is permitted to run')", + "evidence": "the list_actions response" + }, + { + "clause": "the exposed action executes and returns its declared result, whose counts reconcile with direct REST counts", + "oracle": "api", + "verify": "run_action result {accounts, projects, invoices} equals the three REST count queries", + "evidence": "result + the three counts" + }, + { + "clause": "the trusted-elevation is AUDIBLE: the server log carries \"[action-audit] MCP run_action 'showcase_portfolio_snapshot' on …— body executes TRUSTED (system-elevated context, RLS/FLS-bypassing) for user ''\" naming the caller (and the on-behalf-of user for agent principals)", + "oracle": "log", + "verify": "the audit line for the successful call (action-execution.ts #2849/#3914)", + "evidence": "the log excerpt" + }, + { + "clause": "system objects are refused wholesale: run_action against a sys_* object errors with the system-object guard ('… is on a system object and is not exposed via MCP'), never executes", + "oracle": "api", + "verify": "the refusal text + absence of any side effect", + "evidence": "the refusal" + } + ], + "negative": [ + "the refusal path must leave zero side effects — a rejected call that half-executed is a FAIL", + "list_actions advertising an action that run_action then refuses (or vice versa) is a FAIL — the two doors enforce the SAME ai.exposed declaration (action-execution.ts: 'the REST /actions route and the MCP run_action bridge enforce the SAME declaration')" + ], + "traps": ["wrong-persona"], + "source": [ + "packages/runtime/src/action-execution.ts (actionAiExposureError exact string; system-object guard; [action-audit] line; shared REST/MCP gate #3915)", + "packages/mcp/src/mcp-http-tools.ts (list_actions/run_action registration + exposed-and-permitted filter)", + "examples/app-showcase/src/ui/actions/index.ts (showcase_portfolio_snapshot — the seeded ai.exposed fixture)", + "docs/plans/release-15.1-test-plan.md §A9 (#2964) + §G2 (#3010/#3020 standalone action on the bridge)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from the 15.1 plan §A9", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "ai.mcp-validate-expression", + "title": "MCP validate_expression returns build-accurate errors/warnings/inferred type + in-scope context for every site variant, and fail-closes on unknown/system objects", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "api", + "fixtures": { + "app": "showcase", + "requires": [ + "MCP HTTP reachable with an osk_ key (see ai.mcp-http-surface)", + "a seeded object with number + text + date fields to probe against (showcase_task: est_hours/title/created_at qualify)" + ] + }, + "variants": [ + "site: formula (record. bound; inferred type returned)", + "site: validation (record-scoped predicate)", + "site: flow_condition (fields bound BARE — a record.-prefixed ref is the error here)", + "site: template" + ], + "steps": [ + "over MCP, call validate_expression { objectName: 'showcase_task', expression: 'record.est_hours * 1.1', site: 'formula' } — a sound formula", + "call it with a BARE field ref in formula site ('est_hours * 1.1') and with an unknown field ('record.est_hourz * 1'); capture both error sets", + "call it with a text field misused in arithmetic ('record.title * 2'); capture the WARNING (not error)", + "call the flow_condition site with bare fields ('est_hours > 8') — must validate clean", + "call it against a nonexistent object and against a sys_* object; capture both refusals", + "record the inScope payload (dialect/roots/fields/functions) returned alongside each verdict" + ], + "acceptance": [ + { + "clause": "the sound formula returns ok:true with an inferredType and the in-scope fields/functions — the same verdict `objectstack build` would give (the tool exists so agents self-correct BEFORE authoring, #1928)", + "oracle": "api", + "verify": "response has ok:true, inferredType present, inScope.fields includes est_hours", + "evidence": "the response" + }, + { + "clause": "bare-field-in-formula and unknown-field are ERRORS with located, prescriptive text (unknown field carries a did-you-mean where close); text-in-arithmetic is a WARNING, not an error — the error/warning tiers must not blur", + "oracle": "api", + "verify": "three calls, three verdicts matching the tiers pinned in packages/mcp/src/mcp-validate-expression.test.ts", + "evidence": "the three responses" + }, + { + "clause": "site changes the binding rules: bare fields are CORRECT in flow_condition and wrong in formula — the same expression flips verdict across the two sites", + "oracle": "api", + "verify": "'est_hours > 8' ok under flow_condition, error under formula", + "evidence": "the paired responses" + }, + { + "clause": "unknown objects error clearly ('Object \"\" not found') and system objects are refused by the fail-closed guard — the validator is schema introspection, never a data door", + "oracle": "api", + "verify": "both refusal texts; no schema of a sys_* object is leaked in the response", + "evidence": "the two refusals" + } + ], + "negative": [ + "a validation verdict that DIFFERS from the build gate for the same expression is a FAIL — the tool's one job is build-accuracy (api-backend.formula-gates is the same engine)" + ], + "automated": { "kind": "unit", "ref": "packages/mcp/src/mcp-validate-expression.test.ts" }, + "source": [ + "packages/mcp/src/mcp-http-tools.ts (validate_expression registration: input schema incl. the 4-value site enum, VALIDATE_SITE_MAP, response shape)", + "packages/mcp/src/mcp-validate-expression.test.ts (tier pins: error vs warning vs ok, unknown object, system-object guard)", + "#3358 §9 (underlying validateExpression works; MCP path was blocked on the disabled transport)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358 §9", "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" } + ] + }, + { + "id": "ai.skill-instructions-mcp-prompts", + "title": "Authored skill instructions project onto the MCP prompts primitive (#3905) — listed with identity, fetchable by name, and NOT projected when instruction-less or inactive", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "fixtures": { + "app": "any", + "requires": [ + "three authored skills in a writable package: qa_prompt_full (instructions + label + description), qa_prompt_bare (tools only, NO instructions), qa_prompt_off (instructions but active:false)", + "MCP HTTP reachable with an osk_ key (see ai.mcp-http-surface)" + ], + "knownGaps": [ + "the OTHER half of a skill — tools[] / surface / triggerConditions binding — is CLOUD-RUNTIME-ONLY (#3905; skill.zod.ts says so per-key): the open framework has no agent loop to bind to, so this item deliberately does NOT test tool binding or activation; those need a cloud/EE environment" + ] + }, + "steps": [ + "author the three fixture skills; build; boot with MCP enabled", + "open an MCP session (osk_ key) and check the server's declared capabilities for prompts", + "prompts/list; record the projected names", + "prompts/get qa_prompt_full; capture the returned message body", + "prompts/get qa_prompt_bare (not projected) and prompts/get a wholly unknown name; capture both errors", + "flip qa_prompt_off to active:true, reload metadata, and re-list" + ], + "acceptance": [ + { + "clause": "qa_prompt_full is listed as an MCP prompt carrying the skill's name, label (title) and description — the metadata → prompts projection is live in the OPEN framework, not just cloud", + "oracle": "network", + "verify": "prompts/list contains qa_prompt_full with the authored identity (packages/mcp/src/skill-prompts.ts projectSkillPrompt)", + "evidence": "the prompts/list payload" + }, + { + "clause": "prompts/get returns the authored instructions text as the prompt body, re-read from metadata at get time", + "oracle": "network", + "verify": "the message content equals the authored instructions string", + "evidence": "the prompts/get payload" + }, + { + "clause": "a skill with NO instructions is not listed at all (nothing to serve ⇒ not advertised), and an inactive skill is not projected — both absences verified, not assumed", + "oracle": "network", + "verify": "prompts/list omits qa_prompt_bare and qa_prompt_off; after activating qa_prompt_off it appears", + "evidence": "the before/after listings" + }, + { + "clause": "prompts/get for a non-projected or unknown name is rejected with JSON-RPC invalid-params (-32602), not an empty success", + "oracle": "network", + "verify": "both bad gets error with code -32602 (skill-prompts.test.ts pin)", + "evidence": "the two error responses" + }, + { + "clause": "when the host cannot read skill metadata the prompts capability is NOT declared — never advertised-but-empty (graceful degradation, same posture as the tool bridges)", + "oracle": "test", + "verify": "run packages/mcp/src/skill-prompts.test.ts ('declares NO prompts capability when the host cannot read skill metadata') and cite its output — do not hand-build a broken host", + "evidence": "the test output" + } + ], + "negative": [ + "an instruction-less skill appearing in prompts/list is a FAIL — an empty prompt advertisement is the 'declared capability nothing serves' shape this projection was built to avoid", + "prompts/get succeeding for an inactive skill is a FAIL (active:false must withdraw the projection, unlike the retired tool.active which withdrew nothing)" + ], + "traps": ["stale-dist"], + "automated": { "kind": "unit", "ref": "packages/mcp/src/skill-prompts.test.ts" }, + "source": [ + "packages/mcp/src/skill-prompts.ts (#3905 — the projection, its narrowness, and the two-halves boundary)", + "packages/mcp/src/skill-prompts.test.ts (list/get/absence/-32602/capability pins)", + "packages/spec/src/ai/skill.zod.ts (instructions served everywhere; tools/surface/triggerConditions cloud-only)", + "packages/spec/liveness/skill.json (instructions/name/label/description/active live in-repo via skill-prompts.ts since 2026-08-06)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: the open framework's one live skill consumer (#3905) had no checklist coverage", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "ai.open-edition-honest-degradation", + "title": "Open-framework /ai/** degrades honestly: GET /ai/agents answers the empty-catalog courtesy, every other /ai/* answers 501 with the Cloud/EE remedy sentence that discovery also reports", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["admin"], + "fixtures": { + "app": "any", + "requires": ["a stock OPEN-framework boot — no @objectstack/service-ai registered (the default: service-ai is a private Cloud/EE package)"], + "knownGaps": [ + "ModelRegistrySchema (packages/spec/src/ai/model-registry.zod.ts) and ConversationSessionSchema (conversation.zod.ts) have NO open-framework runtime consumer — /ai/models and /ai/conversations/* are served only by cloud service-ai (enumerated in cloud's ai-route-ledger.ts, #3718). This item therefore tests the HONEST REFUSAL, not those payload shapes; asserting model/conversation behavior needs a cloud/EE environment and belongs to that repo's conformance test", + "the ai settings manifest (packages/services/service-settings/src/manifests/ai.manifest.ts) configures the CLOUD adapter selection; it stores values in the open framework but nothing consumes them here — do not tick 'AI configured' off a saved settings form" + ] + }, + "steps": [ + "boot the stock open framework; sign in as admin", + "GET /api/v1/ai/agents; capture status + body shape", + "GET /api/v1/ai/models and GET /api/v1/ai/conversations; capture both", + "POST /api/v1/ai/chat with a minimal body; capture", + "GET /api/v1/discovery and extract the ai service slot's availability + message", + "diff the 501 body message against the discovery message" + ], + "acceptance": [ + { + "clause": "GET /ai/agents answers 200 with the declared envelope carrying { agents: [] } — the deliberate courtesy (#4058) that keeps the console's per-navigation poll from logging spam, and it must be the RELOCATED payload under data (data.agents), not a bare array", + "oracle": "api", + "verify": "status 200; body data.agents is an empty ARRAY (unwrapResponse → .agents readable)", + "evidence": "the response" + }, + { + "clause": "every other /ai/* route answers 501 (route mounted, implementation absent — NOT 404, NOT 503) with the exact remedy: 'Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework'", + "oracle": "api", + "verify": "/ai/models, /ai/conversations, /ai/chat all 501 with that message (REMEDY_DETAIL, packages/spec/src/system/core-services.zod.ts)", + "evidence": "the three responses" + }, + { + "clause": "the 501 body and the discovery entry for the ai slot carry the SAME sentence — the one-source rule that stops the two surfaces prescribing different remedies", + "oracle": "api", + "verify": "discovery's ai slot message string-equals the 501 message (serviceUnavailableMessage is the single writer)", + "evidence": "the diff" + }, + { + "clause": "anonymous-deny still precedes the degradation: an unauthenticated /ai/* request is answered by the auth gate, not by the capability answer", + "oracle": "api", + "verify": "unauthenticated GET /ai/models → 401, not 501", + "evidence": "the 401" + } + ], + "negative": [ + "a 404 ROUTE_NOT_FOUND with the 'check discovery' hint on a mounted /ai/* route is the exact pre-#4058/#3842 failure this item pins — FAIL", + "ticking any AI capability as PRESENT from the 200 empty-agents courtesy (or from a saved ai settings form) is a recording error — the empty catalog IS the 'no AI here' signal" + ], + "traps": ["dispatcher-vs-hono-route"], + "source": [ + "packages/runtime/src/domains/ai.ts (the /ai/agents empty-list courtesy #4058/#4053 + the shared 501 exit)", + "packages/runtime/src/domains/unavailable.ts (501-vs-404-vs-503 rationale; message single-sourced from spec)", + "packages/spec/src/system/core-services.zod.ts (REMEDY_DETAIL['ai'] exact sentence; ai slot 'optional')", + "packages/runtime/src/route-ledger.ts ('* /ai/**' dynamic row — routes owned by cloud; this repo cannot enumerate them)", + "packages/spec/src/ai/model-registry.zod.ts + conversation.zod.ts (schemas exist; no in-repo runtime consumer — the knownGaps basis)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: pins the open/cloud AI boundary as testable behavior (courtesy list + 501 remedy + discovery parity) instead of leaving /ai/** unswept; model-registry & conversation runtime behavior recorded as knownGaps rather than invented", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json new file mode 100644 index 0000000000..0c00890dbc --- /dev/null +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -0,0 +1,1042 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "api-backend", + "title": "REST / batch / formula / build-time gates", + "items": [ + { + "id": "api-backend.batch-transactional-discovery", + "title": "transactionalBatch capability bit matches the mounted /batch behavior — atomicity, rollback codes, size cap", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "personas": [ + "admin (or any member entitled to the objects the batch touches)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "two batch-writable objects (showcase_private_note and showcase_task both accept baseline member creates)", + "default maxBatchSize (200 unless RestServerConfig.batch overrides)" + ] + }, + "steps": [ + "boot showcase isolated; GET /api/v1/discovery and read capabilities.transactionalBatch (also compare client.capabilities.transactionalBatch if driving via @objectstack/client)", + "POST /api/v1/batch with a well-formed cross-object atomic batch: operations = [create showcase_private_note {title}, create showcase_task {title}], options {\"atomic\": true}; verify both rows exist afterwards", + "POST /api/v1/batch with the same operations and options {\"atomic\": false}; capture status + error code", + "POST /api/v1/batch where a MIDDLE operation must fail (e.g. create showcase_invoice missing its required name), sandwiched between two valid creates; capture the per-operation results and re-read all three would-be rows", + "POST /api/v1/batch with operations.length > maxBatchSize (201 no-op creates on the default config); capture the rejection", + "POST /api/v1/data/showcase_private_note/batch (the PER-OBJECT batch door) with a mixed valid/invalid set and NO atomic flag; capture the per-row outcomes" + ], + "acceptance": [ + { + "clause": "the discovery bit is true exactly when /batch is mounted and transaction-capable — capability read and live behavior agree", + "oracle": "api", + "verify": "GET /api/v1/discovery capabilities.transactionalBatch == true AND the atomic batch in step 2 succeeds end-to-end (or, on a runtime without tx support, the bit is false and /batch refuses atomically-dependent use)", + "evidence": "discovery body + batch trace" + }, + { + "clause": "atomic:false on the CROSS-OBJECT /batch answers 400 BATCH_NOT_ATOMIC — the endpoint is all-or-nothing by construction (batch.zod.ts: atomic accepted for symmetry, MUST be true)", + "oracle": "api", + "verify": "step-3 response: status 400, error code BATCH_NOT_ATOMIC, message pointing at POST /data/:object/batch for non-atomic per-object batches", + "evidence": "response" + }, + { + "clause": "a failing member rolls the whole atomic batch back with the #4793 per-row codes: rows before the failure report ROLLED_BACK (written then undone), rows after report NOT_ATTEMPTED (never ran) — and NO row from the batch persists", + "oracle": "api", + "verify": "step-4 per-operation results carry the two codes in the right positions; follow-up GETs find none of the three records", + "evidence": "batch response + the absent-row reads" + }, + { + "clause": "an oversize batch is refused up front with 400 BATCH_TOO_LARGE (the configured maxBatchSize, default 200) and creates nothing", + "oracle": "api", + "verify": "step-5 response: 400 + BATCH_TOO_LARGE; system-context count of the probe-titled rows is 0", + "evidence": "response + count" + }, + { + "clause": "the per-object /data/:object/batch door defaults to NON-atomic per-row outcomes (ADR-0119 D4: atomic defaults false there — the declared-but-unenforced default(true) was the defect): valid rows land, invalid rows report their error, neither blocks the other", + "oracle": "api", + "verify": "step-6 response has per-row success and error entries; re-reads confirm valid rows persisted", + "evidence": "response + re-reads" + } + ], + "negative": [ + "the two batch doors must not be conflated: atomic:false must NOT be accepted-and-ignored on /api/v1/batch (silent non-atomic acceptance is the pre-ADR-0119 bug shape), and the per-object door must NOT silently roll back valid rows when a sibling row fails without atomic being requested" + ], + "traps": [ + "dispatcher-vs-hono-route" + ], + "source": [ + "#3358 §9", + "packages/spec/src/api/batch.zod.ts (ADR-0119 D4)", + "packages/spec/src/api/error-code-ledger.zod.ts (BATCH_NOT_ATOMIC, BATCH_TOO_LARGE, NOT_ATTEMPTED, ROLLED_BACK — #4793)", + "packages/rest/src/rest-route-ledger.ts (batch family)", + "packages/spec/src/api/discovery.zod.ts (transactionalBatch)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "api-backend.formula-gates", + "title": "Formula runtime fixes hold and date-arithmetic fails at build time", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "build", + "steps": [ + "build the workspace (or confirm dist freshness) so @objectstack/formula's BUILT output is what runs — the stale-dist trap is the known false-negative here", + "evaluate the fixed runtime shapes against the built package: due_date == today() (date equality against the macro), cond ? x : null (ternary with null arm), floor(x)/ceil(x)", + "author a scratch object config carrying a formula field with date arithmetic (end - start + 1) and run os build on it", + "run os build on the UNTOUCHED showcase app", + "capture the harness values, both build exit codes, and the located error text" + ], + "acceptance": [ + { + "clause": "the runtime shapes evaluate correctly against the BUILT package (not src)", + "oracle": "test", + "verify": "harness run returns the expected values for the three shapes; the harness imports from dist/node_modules, never src", + "evidence": "harness output" + }, + { + "clause": "date arithmetic is a build-time ERROR in os build — not a runtime surprise", + "oracle": "build", + "verify": "os build exits non-zero on the scratch config", + "evidence": "build output + exit code" + }, + { + "clause": "the build error is LOCATED and actionable: it names the object/field (or file) carrying the offending formula, not just a generic failure", + "oracle": "build", + "verify": "error text contains the scratch field's name/path", + "evidence": "error text" + }, + { + "clause": "the gate does not over-fire: os build on the stock showcase (which uses legitimate date comparisons like due_date == today()) exits 0", + "oracle": "build", + "verify": "untouched showcase os build exit code 0", + "evidence": "build output" + } + ], + "negative": [ + "a green harness run that silently loaded src instead of dist is a false pass — capture the resolved module path as part of the evidence; if the path points into src/, the clause is not-run, not pass" + ], + "traps": [ + "stale-dist" + ], + "source": [ + "#3358 §9" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "api-backend.enforce-or-remove-authoring-gates", + "title": "Removed/retired authoring keys fail at parse/build with located guidance", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "build", + "steps": [ + "sample retired keys across the ADR-0087 registries, at minimum: a query-surface tombstone (QueryAST cursor / joins / distinct / windowFunctions — retiredKey per #4286), the error-envelope tombstone (EnhancedApiError.fieldErrors, renamed to fields by ADR-0114 D4), and one entry from the retired-filter-operator registry in packages/spec/src/data/filter.zod.ts (RetiredFilterOperatorGuidance)", + "author a config/payload using each sampled key: parse the query via the spec schema (QuerySchema.safeParse with cursor: {...}), parse an error envelope carrying fieldErrors, author an object using the retired filter operator; run ObjectSchema.create / os build where the key is config-side", + "capture each rejection's full text", + "author one key documented as silently-stripped-by-design (a pure display annotation) and parse it — the strip must be deliberate, documented behavior, not an accident", + "record which registry each sampled key came from" + ], + "acceptance": [ + { + "clause": "each tombstoned key is REJECTED at parse/build — never silently stripped (the retiredKey mechanism exists precisely because non-strict schemas would otherwise drop the key clean)", + "oracle": "build", + "verify": "safeParse fails / os build exits non-zero for every sampled tombstone", + "evidence": "the rejections" + }, + { + "clause": "every rejection carries LOCATED guidance naming the replacement: cursor → keyset where-predicate on the sort key, joins → expand, fieldErrors → fields (ADR-0114 D4 #3977), retired filter operator → its registry-declared successor", + "oracle": "build", + "verify": "each error text names the replacement spelling; a bare 'unknown key' with no prescription is a FAIL of the guidance contract", + "evidence": "error texts" + }, + { + "clause": "the sampled silently-stripped-by-design key still parses clean AND its documentation says so — the strip stays deliberate and documented, not accidental", + "oracle": "build", + "verify": "parse succeeds; cite the doc/schema comment declaring the strip", + "evidence": "parse result + doc cite" + }, + { + "clause": "the sample covers at least three distinct registries/surfaces (query schema, error envelope, filter operators) — one surface proving the mechanism does not prove the others wired it", + "oracle": "build", + "verify": "run record lists the sampled keys and their registry of origin", + "evidence": "run record" + } + ], + "negative": [ + "also sample one key documented as silently-stripped-by-design (e.g. pure display annotations) to confirm the strip stays deliberate and documented, not accidental — and a tombstoned key that parses CLEAN anywhere is a P1 FAIL (the silent fourth state ADR-0049/0078 forbids)" + ], + "source": [ + "#3358 §9", + "ADR-0049", + "ADR-0087", + "#4286 (query tombstones)", + "ADR-0114 D4 / #3977 (fieldErrors)", + "packages/spec/src/shared/retired-key.ts", + "packages/spec/src/data/filter.zod.ts (RetiredFilterOperatorGuidance)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "api-backend.server-timing-admin-gated", + "title": "Server-Timing spans emit for admins only — on the server os dev actually runs", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "api", + "personas": [ + "admin", + "non-admin member" + ], + "steps": [ + "start the REAL dev server (os dev) with OS_SERVER_TIMING enabled — never a simulated dispatch; the #3361 regression lived exactly in the dispatcher-vs-hono seam while unit tests stayed green", + "as admin: GET /api/v1/data/showcase_task with the X-OS-Debug-Timing request header; dump all response headers", + "as the non-admin member: the identical request; dump headers", + "restart the server WITHOUT OS_SERVER_TIMING and repeat the admin request", + "capture the three header dumps" + ], + "acceptance": [ + { + "clause": "admin responses carry the Server-Timing spans (auth/db/hooks/serialize) on the live hono server", + "oracle": "network", + "verify": "step-2 response has a Server-Timing header naming the span set", + "evidence": "header dump" + }, + { + "clause": "non-admin responses carry NO spans — the gate keys on the caller's privilege, and the payload of the response is otherwise identical (timing must not leak through a side door)", + "oracle": "network", + "verify": "step-3 response lacks Server-Timing entirely", + "evidence": "header dump" + }, + { + "clause": "with the env flag off, even the admin gets no spans — the opt-in is the second gate, both sides verified", + "oracle": "network", + "verify": "step-4 response lacks Server-Timing", + "evidence": "header dump" + }, + { + "clause": "the oracle is the live server os dev runs (dispatcher-vs-hono seam), not a unit-level simulated dispatch", + "oracle": "network", + "verify": "evidence headers come from real HTTP responses against the running dev-server port recorded in the run env", + "evidence": "the raw traces incl. host:port" + } + ], + "negative": [ + "a run that only checks the admin side is at best partial: the non-admin absence is the security half of the item, and it must be measured on the same live server in the same run" + ], + "traps": [ + "dispatcher-vs-hono-route", + "wrong-persona" + ], + "source": [ + "#3358 §9", + "#3361" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358; oracle pinned to the live server because of #3361", + "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" + } + ] + }, + { + "id": "api-backend.query-contract-matrix", + "title": "Data-API query contract: every filter operator gives known answers; $-params, select, sort, expand honored; malformed input 400s with the exact code", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "api", + "personas": [ + "admin (baseline known-answer runs)", + "contributor (the RLS/FLS-restricted persona for the expand enforcement clause)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "seeded showcase_account rows (Northwind, Contoso, Fabrikam, Stark Industries, 华宁科技, … with status ∈ {active, prospect, churned}, sales_region ∈ {amer, emea, apac}, numeric annual_revenue, date signed_on, churn_reason null except churned rows) — the known-answer dataset", + "seeded showcase_task rows (status ∈ {backlog, todo, in_progress, in_review, done}, assignee emails, numeric progress/estimate_hours, project lookup)" + ] + }, + "steps": [ + "boot showcase isolated; as admin GET /api/v1/data/showcase_account with no filter and a generous $top — this unfiltered baseline is the local ground truth every operator answer is computed from (never hardcode counts; the seed can drift)", + "for each operator variant below, run BOTH spellings against showcase_account (or showcase_task where the type fits): the POST /api/v1/data/showcase_account/query body form (where: {field: {$op: value}}) and the AST array form ([field, op, value] — spellings per AST_OPERATOR_MAP); e.g. where {status: {$eq: 'active'}}, {annual_revenue: {$gt: 10000000}}, {status: {$in: ['active','prospect']}}, {annual_revenue: {$between: [5000000, 50000000]}}, {name: {$contains: 'orth'}}, {name: {$startsWith: 'Con'}}, {churn_reason: {$null: true}}", + "for each operator: compute the expected id set from the baseline locally and compare with the returned records — a known-answer check, not a smoke 200", + "pagination window: GET /api/v1/data/showcase_account?$orderby=name&$top=3&$skip=2 and verify it equals rows 3–5 of the locally sorted baseline; also POST body limit/offset/top equivalents", + "selection: ?$select=name,status (and body fields: ['name','status']) — response objects carry exactly the requested keys (plus system identity keys); dotted related column fields: ['account.name'] on showcase_invoice", + "sort: $orderby / orderBy asc and desc on annual_revenue — full ordering compared to the locally sorted baseline, not just first row", + "expand: as the CONTRIBUTOR (restricted persona), POST /api/v1/data/showcase_invoice/query with expand: {contact: {object: 'showcase_contact', fields: ['name']}} on an invoice they own — contributor holds NO showcase_contact grant, so the expanded row must be withheld/masked while their own invoice row returns (15.1 §A6: expand routes through the secured find path, #2850)", + "negative shapes: POST query where {status: {$nin: 'done'}} on showcase_task (scalar comparand for a collection operator — #5869) and the AST twin ['status','not_in','done']; GET with an unknown $-param (?$pageSize=5); GET with a bare unknown key (?not_a_field=x); sort on a nonexistent field; a where clause naming a nonexistent field", + "boundary: where {status: {$in: []}} (must match NOTHING) and {status: {$nin: []}} (must match EVERYTHING) — both documented-legitimate, never 400 (filter-comparand-shape.ts)" + ], + "acceptance": [ + { + "clause": "every operator variant returns exactly the locally-computed answer set from the seeded data, in both the $-object and AST spellings — set equality on ids, not count equality", + "oracle": "api", + "verify": "per-variant diff of returned ids vs the baseline-computed expectation; any extra OR missing row fails that variant", + "evidence": "per-variant diff table" + }, + { + "clause": "pagination is stable and windowed: $top/$skip (and limit/offset/top body keys) return the exact ordered slice of the baseline", + "oracle": "api", + "verify": "slice comparison against the locally sorted baseline for at least two windows", + "evidence": "the two window responses" + }, + { + "clause": "$select/fields narrows the payload to the requested columns (dotted related columns included) — no unrequested business fields leak", + "oracle": "api", + "verify": "key-set assertion on every returned record", + "evidence": "response bodies" + }, + { + "clause": "sort orders the FULL result both directions; INVALID_SORT (400) answers a sort on a nonexistent field", + "oracle": "api", + "verify": "full-sequence comparison asc+desc; then the bad-sort trace shows 400 + code INVALID_SORT", + "evidence": "ordered responses + rejection" + }, + { + "clause": "expand enforces the TARGET object's RLS/FLS on expanded rows (15.1 §A6): the restricted contributor's expand of showcase_contact returns their invoice row with the contact withheld/masked, never the foreign contact's fields", + "oracle": "api", + "verify": "contributor expand response: invoice present, contact expansion absent/null/masked; the SAME query as admin returns the contact (both sides)", + "evidence": "both personas' responses" + }, + { + "clause": "a scalar comparand on a collection operator answers 400 INVALID_FILTER naming the operator, the field, and the expected shape — never 500 DATABASE_ERROR (#5869, both doors: the $-object body AND the AST array spelling)", + "oracle": "api", + "verify": "both step-8 $nin probes: status 400, code INVALID_FILTER, message names the operator and field", + "evidence": "both rejections" + }, + { + "clause": "unknown inputs get their exact ledgered code: unknown $-param → 400 UNSUPPORTED_QUERY_PARAM (listing the supported $-params), unknown bare key or unknown where-field → 400 INVALID_FIELD (#4134 — a filter on a nonexistent field must NEVER answer 200 with rows, in either direction)", + "oracle": "api", + "verify": "the three traces show 400 + the named codes", + "evidence": "rejections" + }, + { + "clause": "empty-list boundary: $in: [] returns zero rows, $nin: [] returns the full visible set, both 200 — arity is not the gate's business, only list-ness", + "oracle": "api", + "verify": "the two responses vs the baseline", + "evidence": "responses" + }, + { + "clause": "every variant below carries a recorded verdict; operators not runnable against seeded types (none expected — the account/task fields cover text/number/date/select/null) are recorded skipped-with-reason, never silently omitted", + "oracle": "api", + "verify": "run record has one row per variant", + "evidence": "run record" + } + ], + "negative": [ + "silent WIDENING is the catastrophic failure mode (#3899): a malformed query body (e.g. {\"filter\": {…}} — not a QueryAST key) must answer 400 VALIDATION_FAILED, never degrade into an unfiltered 200 full read; verify by asserting the malformed-body probe's status AND that its response row count is not the full table" + ], + "variants": [ + "op:eq", + "op:ne", + "op:gt", + "op:gte", + "op:lt", + "op:lte", + "op:in", + "op:not_in", + "op:between", + "op:contains", + "op:not_contains", + "op:starts_with", + "op:ends_with", + "op:like (driver-verbatim pattern, NOT auto-%-wrapped — canonicalAstOperator keeps it distinct)", + "op:is_null", + "op:is_not_null", + "param:$top", + "param:$skip", + "param:$select", + "param:$orderby", + "param:$count", + "param:$search", + "param:$filter", + "param:$expand" + ], + "automated": { + "kind": "unit", + "ref": "packages/objectql/src/engine.test.ts ([#2850] expand sub-read through the secured find path) + packages/objectql/src/filter-comparand-shape.ts (#5869 gate)" + }, + "traps": [ + "seed-data-thin", + "single-datapoint", + "dispatcher-vs-hono-route" + ], + "source": [ + "packages/spec/src/data/filter.zod.ts (AST_OPERATOR_MAP / FieldOperatorsSchema — the operator variant source)", + "packages/spec/src/data/query.zod.ts (QueryAST keys)", + "packages/metadata-protocol/src/protocol.ts (supported $-params + UNSUPPORTED_QUERY_PARAM #2926 ⑩, INVALID_FIELD #4134)", + "#5869 (commit 10c4ea9)", + "release-15.1 plan §A6", + "examples/app-showcase/src/data/seed/index.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — query-contract matrix over the spec operator vocabulary with known-answer checks, per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "api-backend.error-envelope-ledger", + "title": "Sampled endpoints return the standard error envelope with ledgered codes — no invented codes, no retired keys, statuses match the map", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin", + "a baseline member (for the 403 sample)", + "anonymous (for the 401 sample)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_invoice.name is required:true — the cheapest deterministic VALIDATION_FAILED", + "a member persona lacking showcase_announcement create (member_default: create false) — the deterministic 403" + ] + }, + "steps": [ + "boot showcase isolated; induce one error per family and capture the FULL body + status each time:", + "validation: as admin POST /api/v1/data/showcase_invoice {} (missing required name) — expect the field-level envelope", + "auth: anonymous GET /api/v1/data/showcase_private_note — the 401 sample", + "authorization: as the baseline member POST /api/v1/data/showcase_announcement {\"title\": \"probe\"} — the 403 sample", + "not-found: GET /api/v1/data/not_a_real_object and GET /api/v1/data/showcase_task/nonexistent-id-0000 — the two 404 flavors", + "batch: POST /api/v1/batch with options {\"atomic\": false} — the registered-extension-code sample (BATCH_NOT_ATOMIC)", + "query-rejection: GET /api/v1/data/showcase_account?$pageSize=5 — the UNSUPPORTED_QUERY_PARAM sample", + "validate every captured body against the spec: code membership in StandardErrorCode ∪ ERROR_CODE_LEDGER, envelope shape per ErrorResponseSchema/EnhancedApiErrorSchema (noting the REST flat dialect where it is the declared shape), fields[] entries against FieldErrorCode" + ], + "acceptance": [ + { + "clause": "every sampled error code is LEDGERED: code ∈ StandardErrorCode (errors.zod.ts) ∪ ERROR_CODE_LEDGER (error-code-ledger.zod.ts) — an unregistered code is a FAIL per ADR-0112 D3/D4 (no silent fourth state)", + "oracle": "api", + "verify": "for each captured body, membership check of error code against the two spec files", + "evidence": "the captured bodies + the membership table" + }, + { + "clause": "the validation sample carries the field-level array under `fields` with codes from the FIELD-level catalog (lowercase snake, ADR-0114 D2): missing name → fields[] entry {field: 'name', code: 'required'} with a localized message and label", + "oracle": "api", + "verify": "step-2 body: top-level code VALIDATION_FAILED, fields[0].field == 'name', fields[0].code == 'required'", + "evidence": "the body" + }, + { + "clause": "the retired `fieldErrors` key NEVER appears in any sampled body (ADR-0114 D4 tombstone — producers emit `fields`)", + "oracle": "api", + "verify": "key-absence assertion across all captured bodies", + "evidence": "the bodies" + }, + { + "clause": "HTTP status matches the code's declared mapping (HttpStatusErrorCodeMap): 400 validation/filter/param codes, 401 UNAUTHENTICATED, 403 PERMISSION_DENIED, 404 not-found flavors", + "oracle": "api", + "verify": "status-vs-code table across the samples; any mismatch (e.g. a 500 carrying a caller-fixable code) fails", + "evidence": "the table" + }, + { + "clause": "the two 404 flavors are distinguishable in the body (unknown OBJECT vs unknown RECORD) so a client can tell schema drift from data absence", + "oracle": "api", + "verify": "compare the two step-5 bodies: different messages/codes identifying object-level vs record-level not-found", + "evidence": "both bodies" + }, + { + "clause": "one sample per family variant below is captured — a family not sampled leaves the item partial", + "oracle": "api", + "verify": "run record carries one verdict per family", + "evidence": "run record" + } + ], + "negative": [ + "any sampled failure answering 500 INTERNAL/UNCLASSIFIED for input the CALLER can fix is a FAIL of this item (the #5869 class — a server-fault code for a client mistake); likewise a 2xx on any of the induced-error probes is a FAIL (silent success)" + ], + "variants": [ + "family:validation", + "family:auth-401", + "family:authz-403", + "family:not-found-object", + "family:not-found-record", + "family:batch-extension-code", + "family:query-rejection" + ], + "traps": [ + "dispatcher-vs-hono-route" + ], + "source": [ + "packages/spec/src/api/error-code-ledger.zod.ts (ADR-0112 D3)", + "packages/spec/src/api/errors.zod.ts (StandardErrorCode, FieldErrorCode, HttpStatusErrorCodeMap)", + "packages/spec/src/shared/error-map.zod.ts", + "packages/rest/src/rest-route-ledger.ts (sampled families)", + "ADR-0114 D2/D4" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — error-envelope conformance sampling grounded in the two-tier code ledger, per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "api-backend.bulk-write-contract", + "title": "Bulk write doors (createMany/updateMany/deleteMany, per-object batch): per-row outcomes, size cap, single/bulk parity, no silent full-table writes", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "a baseline member (bulk writes on showcase_private_note — also exercises the owner-scoping of #2982)", + "admin (cap and parity runs)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_private_note (baseline create/edit — the bulk probe object)", + "default maxBatchSize 200 unless overridden" + ] + }, + "steps": [ + "boot showcase isolated; as the member POST /api/v1/data/showcase_private_note/createMany with records: [3 notes with unique-marker titles]; GET them back", + "single/bulk parity: POST a 4th note via the single door (POST /api/v1/data/showcase_private_note) and diff its response/stored shape against one createMany row (same fields stamped: owner_id, timestamps, defaults)", + "as the member POST /api/v1/data/showcase_private_note/updateMany with a where clause matching ONLY the marker titles and a body change; re-read", + "as the member POST updateMany with a where clause matching NOTHING (an impossible marker); capture the response", + "as admin POST createMany with records.length == maxBatchSize+1 (201 on default config); capture", + "as the member POST deleteMany scoped to the marker titles; re-read; then re-issue the same deleteMany (now matching nothing)", + "throughout: capture per-row result arrays and any ERR_BULK_RESULT_MISMATCH appearance" + ], + "acceptance": [ + { + "clause": "createMany lands every row with the same server-side stamping as the single door: owner_id auto-stamped to the caller, defaults applied — single/bulk parity on the stored shape", + "oracle": "api", + "verify": "field-by-field diff of a createMany row vs the single-door row (ignoring ids/timestamps values, comparing key sets and stamped semantics)", + "evidence": "the reads + diff" + }, + { + "clause": "updateMany applies exactly to the where-matched set: marker rows changed, every other row untouched (spot-check via a system-context read of a non-marker note)", + "oracle": "api", + "verify": "re-reads: all marker rows carry the change; the control row does not", + "evidence": "re-reads" + }, + { + "clause": "an empty match is a calm no-op: updateMany/deleteMany matching nothing answer 2xx reporting 0 affected — NOT an error, and NEVER a full-table write (the #2982 failure shape was bulk writes escaping their scope)", + "oracle": "api", + "verify": "step-4/6 responses report zero affected; a follow-up unfiltered system read shows no unexpected mutations/deletions", + "evidence": "responses + control read" + }, + { + "clause": "the size cap holds on the bulk doors: maxBatchSize+1 records answer 400 BATCH_TOO_LARGE and create nothing", + "oracle": "api", + "verify": "step-5 response 400 + BATCH_TOO_LARGE; count of that batch's marker rows is 0", + "evidence": "response + count" + }, + { + "clause": "deleteMany removes exactly the scoped set and the deletion persists", + "oracle": "api", + "verify": "post-delete GET finds zero marker rows; the member's other notes remain", + "evidence": "re-reads" + }, + { + "clause": "ERR_BULK_RESULT_MISMATCH never surfaces on these healthy paths — its appearance means the engine's reported outcome diverged from the driver's actual writes and is an immediate FAIL with the trace attached", + "oracle": "api", + "verify": "grep the captured responses (and server log) for ERR_BULK_RESULT_MISMATCH", + "evidence": "responses + log excerpt" + } + ], + "negative": [ + "run the updateMany-matching-nothing probe as a persona with rows it CANNOT see and confirm the invisible rows are not counted or mutated (bulk writes are RLS-scoped, #2982) — a nonzero affected-count there is a security FAIL, not a bookkeeping quirk" + ], + "variants": [ + "door:createMany", + "door:updateMany", + "door:deleteMany", + "door:per-object-batch (POST /data/:object/batch)" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts (bulk halves)" + }, + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/rest/src/rest-route-ledger.ts (batch family — the four doors)", + "packages/spec/src/api/batch.zod.ts", + "packages/spec/src/api/error-code-ledger.zod.ts (BATCH_TOO_LARGE, ERR_BULK_RESULT_MISMATCH)", + "#2982", + "release-15.1 plan §A3/A4" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — bulk-write contract item (boundaries, parity, per-row outcomes), per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "api-backend.route-ledger-live-parity", + "title": "Every ledgered route family — REST, dispatcher, auth, storage/i18n services — and the non-ledgered mounts (/api/settings, /api/v1/datasources) are actually mounted on the live server — no route that exists only in unit tests", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "api", + "personas": [ + "admin (authenticated, so a 401 cannot mask a 404)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "packages/rest/src/rest-route-ledger.ts — the audited route inventory (route-manager + direct-mount rows, full wire paths at /api/v1)" + ] + }, + "steps": [ + "boot the real HTTP stack (os dev); read REST_ROUTE_LEDGER and pick at least one representative route per family (discovery, openapi, metadata, ui, crud, batch, data-actions, forms, search, security, security-explain, record-shares, sharing-rules, reports, approvals, email, analytics, packages, external-datasource)", + "fire each sampled route as admin with a minimal-valid shape (GETs verbatim; parameterized routes filled with real seeded names, e.g. GET /api/v1/data/showcase_task, GET /api/v1/meta/object/showcase_task, GET /api/v1/security/explain)", + "capture status + code per route", + "read the OTHER ledgers and fire one representative route each: the dispatcher ledger (packages/runtime/src/route-ledger.ts) families share-links/keys/notifications/suggested-bindings/i18n/analytics (e.g. GET /api/v1/share-links, POST /api/v1/keys, GET /api/v1/notifications, GET /api/v1/security/suggested-bindings, GET /api/v1/i18n/locales, POST /api/v1/analytics/query), AUTH_ROUTE_LEDGER (GET /api/v1/auth/get-session), the storage + i18n service ledgers", + "fire the NON-LEDGERED mounts: GET /api/settings (note the /api/settings base — NOT /api/v1), GET /api/v1/datasources/drivers, GET /api/v1/datasources", + "fire the dispatcher meta state route: GET /api/v1/meta/objects/showcase_task/state/status?from=in_review, the same with ?from omitted, and GET /api/v1/meta/objects/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)" + ], + "acceptance": [ + { + "clause": "every sampled ledger route answers something OTHER than a routing 404 on the live server — 2xx, or a structured 4xx/5xx from the handler (401/403/400/503 all prove the route is mounted); the #3361 lesson: unit-level dispatch proves nothing about the hono server", + "oracle": "api", + "verify": "per-route status table; any ledgered route answering the same not-found shape as the 404 control is a FAIL (mounted-in-tests-only)", + "evidence": "the status table + control trace" + }, + { + "clause": "the 404 control behaves as a control: the unmounted path answers the routing not-found shape, so the per-route comparison is meaningful", + "oracle": "api", + "verify": "control trace shows the distinct routing-404 body", + "evidence": "control trace" + }, + { + "clause": "openapi.json answers its DECLARED envelope either way: 200 with an OpenAPI 3.1 document, or 503 OPENAPI_UNAVAILABLE when no spec is bundled — never a raw 404", + "oracle": "api", + "verify": "GET /api/v1/openapi.json status ∈ {200, 503} with the ledgered code on 503", + "evidence": "trace" + }, + { + "clause": "discovery capability bits agree with live behavior for the sampled capabilities (a bit true ⇒ the family answers; a bit false ⇒ the family refuses coherently)", + "oracle": "api", + "verify": "cross-check the discovery body against the sampled families' answers", + "evidence": "discovery body + table" + }, + { + "clause": "direct-mount rows (package-routes, external-datasource-routes — the registrars that bypass RouteManager) are sampled too, since their registration path is exactly the one route-manager enumeration misses", + "oracle": "api", + "verify": "at least one direct-mount route in the sample set answers non-routing-404", + "evidence": "trace" + }, + { + "clause": "the sweep reaches the OTHER ledgers, not just the 19 REST families: the DISPATCHER ledger's representative routes each answer non-routing-404 on the live hono server — share-links (GET /api/v1/share-links), keys (POST /api/v1/keys), notifications (GET /api/v1/notifications), suggested-bindings (GET /api/v1/security/suggested-bindings), i18n (GET /api/v1/i18n/locales), analytics (POST /api/v1/analytics/query, capability-conditional) — the #3361 dispatcher-vs-hono class this item exists for, now covered on the dispatcher's OWN table (packages/runtime/src/route-ledger.ts), not just the REST one", + "oracle": "api", + "verify": "per-route status table for the six dispatcher families vs the 404 control; a dispatcher route answering the routing-404 shape is a FAIL (dispatcher-mounted-in-tests-only)", + "evidence": "the dispatcher status table + control trace" + }, + { + "clause": "the auth and service ledgers are swept too: a representative route from AUTH_ROUTE_LEDGER (packages/plugins/plugin-auth/src/auth-route-ledger.ts — e.g. GET /api/v1/auth/get-session), the storage service ledger (packages/services/service-storage/src/storage-route-ledger.ts) and the i18n service ledger (packages/services/service-i18n/src/i18n-route-ledger.ts) each answer non-routing-404 — the three surfaces #3636 ledgered OUTSIDE @objectstack/rest", + "oracle": "api", + "verify": "one representative route per ledger answers non-404; read each ledger for its own representative route rather than guessing paths", + "evidence": "the per-ledger traces" + }, + { + "clause": "the NON-LEDGERED mounts answer too, and their absence from any route ledger is recorded as the finding: GET /api/settings (service-settings, mounted at /api/settings — NOT under /api/v1) answers non-404, and GET /api/v1/datasources/drivers (always-available static catalog) plus GET /api/v1/datasources (200, or 503 SERVICE_UNAVAILABLE when the admin service is unwired) answer non-404 — yet neither /api/settings nor the /api/v1/datasources admin CRUD appears in packages/rest/src/rest-route-ledger.ts (the tranche-3 route-ledger discipline gap, PENDING-GAPS §E)", + "oracle": "api", + "verify": "the /api/settings and /api/v1/datasources traces are non-routing-404; the run record notes both mounts are unledgered", + "evidence": "the two traces + the unledgered-mount finding" + }, + { + "clause": "the dispatcher meta state route is live AND correct: GET /api/v1/meta/objects/showcase_task/state/status?from=in_review (dispatcher ledger meta.getLegalNextStates, ADR-0020 D3.3) answers non-404 and returns next == ['done','in_progress'] — exactly the declared task_status_flow transition set for that state; ?from omitted returns next:null (no from ⇒ no transition table), a field with no FSM returns next:null, and an unknown object → 404", + "oracle": "api", + "verify": "the state-route response's next[] equals the object's state_machine transitions for the from-state (examples/app-showcase/src/data/objects/task.object.ts task_status_flow: in_review → [done, in_progress]); the null/404 controls hold", + "evidence": "the state-route responses (from=in_review, from-omitted, unknown-object) vs the declared transitions" + } + ], + "negative": [ + "do not tick this from RestServer.getRoutes() output or unit tests — the item exists because that oracle lied (#3361, dispatcher-vs-hono-route); the only admissible evidence is live HTTP traces from the running server" + ], + "variants": [ + "discovery", + "openapi", + "metadata", + "ui", + "crud", + "batch", + "data-actions", + "forms", + "search", + "security", + "security-explain", + "record-shares", + "sharing-rules", + "reports", + "approvals", + "email", + "analytics", + "packages (direct-mount)", + "external-datasource (direct-mount)", + "dispatcher:share-links", + "dispatcher:keys", + "dispatcher:notifications", + "dispatcher:suggested-bindings", + "dispatcher:i18n", + "dispatcher:analytics (capability-conditional)", + "dispatcher:meta-state-route (meta.getLegalNextStates)", + "ledger:auth (AUTH_ROUTE_LEDGER)", + "ledger:storage-service", + "ledger:i18n-service", + "unledgered:/api/settings", + "unledgered:/api/v1/datasources" + ], + "traps": [ + "dispatcher-vs-hono-route" + ], + "source": [ + "packages/rest/src/rest-route-ledger.ts (variant source — the 19 REST families)", + "packages/rest/src/rest-route-ledger.conformance.test.ts", + "packages/runtime/src/route-ledger.ts (dispatcher ledger — share-links/keys/notifications/suggested-bindings/i18n/analytics families + the meta.getLegalNextStates state route)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (AUTH_ROUTE_LEDGER — the enumerated better-auth table, #3656)", + "packages/services/service-storage/src/storage-route-ledger.ts + packages/services/service-i18n/src/i18n-route-ledger.ts (tranche-3 per-service ledgers, #3636)", + "packages/services/service-settings/src/settings-routes.ts (/api/settings — non-/api/v1 mount, UNLEDGERED)", + "packages/services/service-datasource/src/admin-routes.ts (/api/v1/datasources admin CRUD — UNLEDGERED, tranche-3 gap)", + "examples/app-showcase/src/data/objects/task.object.ts (task_status_flow transitions for the meta state-route clause)", + "docs/qa/platform-checklist/PENDING-GAPS.md §D/§E", + "#3587", + "#3361" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new — live-mount parity sweep over the audited route ledger, per the deep-test contract", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "extended the sweep beyond the 19 REST families to the OTHER ledgers + non-ledgered mounts: dispatcher ledger (share-links/keys/notifications/suggested-bindings/i18n/analytics), AUTH_ROUTE_LEDGER, storage/i18n service ledgers, /api/settings, /api/v1/datasources; added a live-mount clause per ledger with a 404 control (the #3361 dispatcher-vs-hono class), and folded in the meta.getLegalNextStates state route (legal next states == declared task_status_flow set) since api-backend is its natural home", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "api-backend.declarative-endpoint-execution", + "title": "Metadata-authored `api` endpoints mount as real URLs with their declared auth/cache policy", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin", + "anonymous" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the two live declarative endpoints in examples/app-showcase/src/system/apis/index.ts (a GET task feed at /api/v1/apps/showcase/tasks and a flow-delegating endpoint) — ADR-0121 / #5040" + ] + }, + "variants": [ + "object_operation target — the task feed (authed read)", + "flow target — delegates to a flow", + "policy: authRequired (default true)", + "policy: cacheTtl → Cache-Control", + "script/proxy target — answer 501 in the open framework" + ], + "steps": [ + "boot showcase isolated; sign in as admin", + "GET /api/v1/apps/showcase/tasks authed; capture status, body, and response headers", + "GET the same endpoint unauthenticated; capture status", + "author a script-target endpoint shape in a scratch package and hit it; capture the 501", + "GET /apps/showcase/nope (no matching declaration); capture the transport 404" + ], + "acceptance": [ + { + "clause": "the object_operation endpoint answers 200 authed with the delegated data, and carries the declared cache policy (Cache-Control: private, max-age=30 when cacheTtl is set)", + "oracle": "api", + "verify": "authed GET status 200 + body + Cache-Control header vs the declared policy", + "evidence": "response + headers" + }, + { + "clause": "authRequired defaults true — the anonymous call is refused 401 UNAUTHENTICATED, not served", + "oracle": "api", + "verify": "unauth GET status 401 + code", + "evidence": "the response" + }, + { + "clause": "a script/proxy target answers 501 in the open framework (declared ≠ delivered surfaces honestly, not a fake 200)", + "oracle": "api", + "verify": "the 501 envelope", + "evidence": "the response" + }, + { + "clause": "an unmatched path under /apps/** falls through to the transport 404 — the notFound seam does not swallow it", + "oracle": "api", + "verify": "GET a nonexistent app path → 404", + "evidence": "the response" + } + ], + "negative": [ + "a declarative endpoint serving anonymously when authRequired is true (or unset) is a FAIL — the default is closed" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts + declarative-endpoint-policy.dogfood.test.ts" + }, + "traps": [ + "dispatcher-vs-hono-route" + ], + "source": [ + "examples/app-showcase/src/system/apis/index.ts (the two live endpoints)", + "ADR-0121 / #5040 (declarative endpoint E-series)", + "packages/runtime notFound-fallback mount seam" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — the coverage.json `api` waiver was STALE (showcase authors two live endpoints with dogfood pins); un-waived", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "api-backend.package-rest-lifecycle", + "title": "Package REST lifecycle: POST /packages creates (201), a duplicate name is refused 409 (no silent manifest clobber), PATCH partial-patches the manifest, and an explicit overwrite re-install replaces in place without duplicating", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "admin (manage_metadata / package authoring)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a runtime that accepts package install/patch over HTTP (os dev's dispatcher install route, packages.install → POST /api/v1/packages); a scratch package id (e.g. qa_pkg_lifecycle_probe) so no shipped package is mutated" + ], + "knownGaps": [ + "if the deployment blocks runtime package install (read-only metadata), record it as a fixture requirement and treat the item blocked(environment) rather than failing the install probes" + ] + }, + "steps": [ + "boot showcase isolated; sign in as admin; choose a scratch id qa_pkg_lifecycle_probe", + "POST /api/v1/packages with { manifest: { id: 'qa_pkg_lifecycle_probe', name: 'QA Probe', version: '1.0.0', scope: 'custom', type: 'app' } }; capture status + body; then GET /api/v1/packages/qa_pkg_lifecycle_probe to confirm it persisted (the dispatcher install lands in BOTH the in-memory registry and durable sys_packages)", + "POST the SAME manifest again with NO overwrite flag; capture status + body", + "PATCH /api/v1/packages/qa_pkg_lifecycle_probe with { name: 'QA Probe Renamed', description: 'edited', version: '1.1.0' }; GET the package back and confirm the patch persisted and that id/scope/type and the lifecycle fields (enabled/status/installedAt) are untouched", + "PATCH three malformed bodies: { name: '' }, { version: 'not-semver' }, and {} (nothing to update); capture each rejection", + "re-install with the explicit opt-in: POST /api/v1/packages?overwrite=true (or body { overwrite: true }) carrying the same id and a changed manifest (version 2.0.0); capture status", + "GET /api/v1/packages and count rows whose id == qa_pkg_lifecycle_probe — must be exactly one", + "confirm the LIVE routing seam: POST /api/v1/packages resolves to the dispatcher install route (REST moved marketplace publish OFF the bare path to POST /api/v1/packages/publish in #3610), and PATCH /api/v1/packages/:id answers non-404 though it is absent from packages/rest/src/rest-route-ledger.ts (dispatcher-only)" + ], + "acceptance": [ + { + "clause": "a scratch package installs → 201: POST /api/v1/packages returns 201 with the created package, and a follow-up GET reads it back (the install lands in the in-memory registry AND durable sys_packages, per packages.ts routing through protocol.installPackage)", + "oracle": "api", + "verify": "POST status 201; GET /api/v1/packages/qa_pkg_lifecycle_probe returns the package", + "evidence": "the POST + GET responses" + }, + { + "clause": "a duplicate name is refused 409, NEVER silently overwritten: the second POST (no overwrite) answers 409 with message \"Package 'qa_pkg_lifecycle_probe' already exists\" and the bare-409 derived code RESOURCE_CONFLICT (HttpStatusErrorCodeMap 409) — the #2995 data-loss footgun (a silent re-install destroying the existing manifest) is closed", + "oracle": "api", + "verify": "step-3 response: status 409, code RESOURCE_CONFLICT, message names the existing id; the stored manifest is unchanged from step 2", + "evidence": "the 409 response + a re-read proving the manifest survived" + }, + { + "clause": "PATCH is a real partial patch: only name/description/version present are changed and read back; identity (id/scope/type) and lifecycle (enabled/status/installedAt) are preserved — a PATCH is not a full replace", + "oracle": "api", + "verify": "step-4 GET: the three patched fields updated, the identity+lifecycle fields byte-identical to before the PATCH", + "evidence": "before/after package reads" + }, + { + "clause": "PATCH validates its inputs: an empty name → 400 ('name must not be empty'), a non-semver version → 400 ('version must be semantic (e.g. 1.0.0)'), and a nothing-to-update body → 400 — each a caller-fixable 400, never a 500", + "oracle": "api", + "verify": "the three step-5 responses are 400 with the located messages", + "evidence": "the three rejections" + }, + { + "clause": "the explicit overwrite re-install replaces in place WITHOUT duplicating: POST with overwrite=true (body or query) succeeds and the subsequent GET /api/v1/packages lists exactly ONE row for the id — the overwrite is the deliberate opt-out of the 409 guard, and it never leaves two package rows behind", + "oracle": "api", + "verify": "step-6 status is a success (201/200); step-7 count of qa_pkg_lifecycle_probe rows == 1; the manifest reflects the overwrite (version 2.0.0)", + "evidence": "the overwrite response + the deduped list" + }, + { + "clause": "the sweep is against the LIVE server, not one ledger: POST /api/v1/packages is served by the dispatcher install route (not the REST marketplace publish moved to /packages/publish in #3610), and PATCH /api/v1/packages/:id answers non-404 despite being absent from the REST route ledger", + "oracle": "api", + "verify": "POST /api/v1/packages installs (not a 400 publish-shape rejection); PATCH /api/v1/packages/:id answers non-routing-404", + "evidence": "the two traces" + } + ], + "negative": [ + "a second POST of the same id answering 2xx and silently replacing the manifest is the #2995 data-loss FAIL (the whole reason the 409 guard exists)", + "a PATCH that resets enabled/status/installedAt (a full replace masquerading as a partial patch) is a FAIL", + "an overwrite re-install that leaves two rows for one id in GET /api/v1/packages is a FAIL (duplication, not replacement)" + ], + "traps": [ + "dispatcher-vs-hono-route" + ], + "source": [ + "packages/runtime/src/domains/packages.ts (POST install: pkgId-required 400, 409-duplicate guard + overwrite opt-in #2995; PATCH partial-patch validators; routes through protocol.installPackage / protocol.updatePackage)", + "packages/runtime/src/route-ledger.ts (POST /packages → packages.install, PATCH /packages/:id → packages.update, GET /packages → packages.list)", + "packages/rest/src/rest-route-ledger.ts (POST /api/v1/packages/publish moved off the bare POST /packages in #3610; GET/DELETE /packages/:id direct-mount shadows)", + "packages/spec/src/api/errors.zod.ts (HttpStatusErrorCodeMap 409 → RESOURCE_CONFLICT)", + "PENDING-GAPS §G3 (#2995 dup-clobber, #2971, #3007)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — package REST lifecycle (create/409-dup/patch/overwrite-reinstall), grounded in the dispatcher packages domain and the 409 data-loss guard; per PENDING-GAPS §G3", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "api-backend.api-console-discovery-execute", + "title": "Studio Developer API Console: the endpoint tree mirrors /discovery, a seeded-object GET executes with live JSON + status + timing, and a malformed body surfaces the server error envelope verbatim", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the console Developer hub reachable (objectui apps/console/src/pages/developer/ApiConsolePage.tsx, developer:api-console route)", + "stock showcase seed with showcase_task rows (the seeded object the GET executes against)" + ] + }, + "steps": [ + "boot showcase isolated; sign in as admin; open Studio → Developer → API Console", + "GET /api/v1/discovery out of band and record its services + routes maps — the ground truth the tree is gated on (useApiDiscovery gates each service group on isServiceUsable(discovery.services[name]), ADR-0076 D12)", + "screenshot the endpoint tree AFTER the 'Discovering APIs' spinner settles; only then read the DOM", + "spot-check the tree against REST_ROUTE_LEDGER families: a seeded object (showcase_task) shows its CRUD family (GET/POST/PATCH/DELETE /api/v1/data/showcase_task) under a Data group, the Metadata group lists /api/v1/meta/object/showcase_task, System lists discovery/packages/health", + "select GET /api/v1/data/showcase_task and click Send; capture the response pane: status code, the JSON body, and the duration (ms)", + "cross-check the pane's JSON against a direct GET of the same URL (the page fetches `${client.baseUrl}${url}` — the same wire call)", + "select POST /api/v1/data/showcase_task, enter a MALFORMED body (invalid JSON, or a body missing the required fields), and Send; capture the response pane" + ], + "acceptance": [ + { + "clause": "the tree mirrors /discovery: every rendered service group corresponds to a service the /discovery payload reports usable (isServiceUsable) — a stub/unavailable service renders NO group — and a seeded object's CRUD + meta families appear, spot-checked against REST_ROUTE_LEDGER", + "oracle": "screenshot", + "verify": "the tree screenshot cross-checked name-by-name against the pre-captured /discovery services map; the showcase_task CRUD + meta entries are present", + "evidence": "tree screenshot + the /discovery body, diffed" + }, + { + "clause": "a seeded-object GET executes to LIVE JSON + status + timing: Send on GET /api/v1/data/showcase_task yields status 200, a JSON body of real seeded rows, and a duration reading — and the body matches a direct GET of the same URL", + "oracle": "network", + "verify": "response-pane status 200 + JSON rows == direct GET; the pane shows a non-zero ms duration", + "evidence": "the response-pane screenshot + the direct GET response" + }, + { + "clause": "status + timing are the REAL response's, not faked: the pane's status code (and its color band) and the ms figure are computed from the actual fetch (performance.now delta) — a 4xx endpoint shows its real 4xx, a network failure shows status 0 (the page's own catch), distinguishable from a server envelope", + "oracle": "network", + "verify": "drive one endpoint that answers 4xx and confirm the pane shows that 4xx status, not a 200", + "evidence": "the 4xx pane + its trace" + }, + { + "clause": "a malformed body surfaces the server error envelope VERBATIM: an invalid/under-specified POST body renders the server's standard error envelope (ledgered code + message) in the response pane exactly as returned — not a swallowed client-side error, not a fabricated success", + "oracle": "network", + "verify": "the malformed POST's pane shows the server's 400 envelope (code ∈ the ledger, e.g. VALIDATION_FAILED) identical to the raw HTTP response", + "evidence": "the pane + the raw response body" + }, + { + "clause": "the discovery→console pipeline reaches the request form: the tree is searchable/collapsible and selecting an endpoint populates method + URL + any body template — read the DOM only after the screenshot confirms the pane rendered", + "oracle": "dom", + "verify": "selecting an endpoint sets the method dropdown and URL input; the body textarea appears for POST/PATCH/PUT", + "evidence": "DOM excerpt after the render screenshot" + } + ], + "negative": [ + "a tree entry for a service /discovery reports unusable (stub/unavailable) is a FAIL — the discovery payload is the authority, not a hardcoded catalog", + "a response pane showing a 2xx/blank for a request the server actually refused (or masking the server envelope behind a generic client message) is a FAIL" + ], + "traps": [ + "stale-console-bundle", + "hydration-race", + "wrong-panel" + ], + "source": [ + "objectui apps/console/src/pages/developer/ApiConsolePage.tsx (the API console: raw fetch to `${client.baseUrl}${url}`, status/duration/JSON pane, request history)", + "objectui apps/console/src/pages/developer/hooks/useApiDiscovery.ts (tree built from GET /api/v1/discovery services/routes + client.meta types/objects; isServiceUsable gate per ADR-0076 D12)", + "packages/rest/src/rest-route-ledger.ts (the REST families the tree is spot-checked against)", + "packages/spec/src/api/discovery.zod.ts (the discovery payload)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — Studio Developer API Console: discovery-mirrored tree, live GET execution (JSON+status+timing), malformed-body envelope passthrough; per PENDING-GAPS §C", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json new file mode 100644 index 0000000000..e5fb16b1e9 --- /dev/null +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -0,0 +1,820 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "approvals", + "title": "Approvals — quorum, per-group sign-off (会签), inbox", + "items": [ + { + "id": "approvals.per-group-signoff", + "title": "Per-group sign-off (会签) needs one approval from EACH group", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": ["approver holding exactly one group (e.g. manager)", "second approver holding the other group (e.g. finance/auditor)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a launched per_group request whose two groups resolve to two DISTINCT users (showcase: ExpenseSignoffFlow on EXP-2001, Ada Auditor holds auditor — seeded since #3409)", + "seed-approval-demo.ts wiring: dev admin holds manager/finance/legal/exec but NOT auditor, so the manager group resolves to the admin and the finance group to Ada Auditor only; EXP-2001 ($1,500) sits under the $5,000 committee threshold so the quorum flow does not also open on it; submitter is Mei Phone (usr_showcase_phone_demo)" + ], + "knownGaps": [ + "Ada Auditor exists as a routable sys_user row only — better-auth sign-in for her needs an account provisioned at runtime (seed-approval-demo.ts: 'sign-in still needs a better-auth account'); the finance-group decision therefore needs either a provisioned Ada account or the server-granted admin override (can_override) — the run record must state which path was used" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin (the manager-group holder; NOT in the finance group of this node)", + "GET /api/v1/approvals/requests?status=pending and locate the EXP-2001 request opened by showcase_expense_signoff's per_group node (groups manager + finance)", + "GET /api/v1/approvals/requests/:id — record status, pending_approvers, pending_approver_names, pending_approver_groups, and the per-group tallies", + "open the inbox at /system/approvals (待我审批 tab); open the request drawer; screenshot the server-computed group chips", + "approve via the drawer dialog (ref-targeted click; fill the comment field); capture the decision POST /api/v1/approvals/requests/:id/approve", + "re-read the request via API; then complete the finance group's decision (as Ada, or via the documented override path) and re-read again", + "GET /api/v1/approvals/requests/:id/actions for the full decision timeline", + "GET /api/v1/automation/showcase_expense_signoff/runs/:runId — the parked flow run before and after finalization" + ], + "acceptance": [ + { + "clause": "before any decision: status=pending, BOTH groups at 0/1, pending list names two DISTINCT user ids (admin for manager, Ada for finance)", + "oracle": "api", + "verify": "GET the request; assert status + per-group tallies + two distinct entries in pending_approvers with pending_approver_groups mapping them to manager / finance", + "evidence": "the initial request read" + }, + { + "clause": "after the manager-group approval: that group is satisfied and dropped from the slate, the request STILL pending on the finance group only", + "oracle": "api", + "verify": "re-read: status=pending; pending_approvers contains only the finance holder; the manager group's tally reads satisfied", + "evidence": "before/after request reads" + }, + { + "clause": "the drawer's group chips are server-computed and match the API at each stage", + "oracle": "screenshot", + "verify": "chip screenshot at each stage alongside the API read — chips are keyed by (name, group) from pending_approver_groups (#2762), not a client recount", + "evidence": "screenshots + paired reads" + }, + { + "clause": "after the finance-group approval: the request finalizes approved and the flow run resumes down its approve edge to the Approved end", + "oracle": "api", + "verify": "request status=approved; the showcase_expense_signoff run transitions paused→completed with the approve branch taken", + "evidence": "final request read + run read" + }, + { + "clause": "the decision timeline carries one approval action per group with distinct actors and round-tripped comments", + "oracle": "api", + "verify": "GET /:id/actions: two approve rows, two distinct actor ids, each carrying the comment submitted in its dialog", + "evidence": "actions read" + }, + { + "clause": "the server derives the decision actor from the session — the posted actorId is a hint, not an authority (#3800)", + "oracle": "network", + "verify": "capture the approve POST; the recorded action row's actor is the authenticated session user regardless of any actorId in the body", + "evidence": "decision POST + action row" + } + ], + "negative": [ + "contrast case: a request whose slots all resolve to ONE user finalizes on a single decision — confirm per_group did NOT (that contrast is the proof the behavior differs)", + "a user in neither group POSTing /api/v1/approvals/requests/:id/approve directly must get a server-side FORBIDDEN — an approval recorded for a non-member is a FAIL even if the UI never offered the button" + ], + "traps": ["automation-input", "wrong-persona"], + "source": [ + "#3358 §1", "#3409", "#3411", + "examples/app-showcase/src/automation/flows/index.ts (ExpenseSignoffFlow, behavior per_group)", + "examples/app-showcase/src/security/seed-approval-demo.ts (distinct-holder wiring + submitter stamping)", + "packages/spec/src/automation/approval.zod.ts (behavior enum + group labels, #3266)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from the #3358 evidence run (decisive oracle: group drops but request stays pending)", "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" } + ] + }, + { + "id": "approvals.quorum-m-of-n", + "title": "M-of-N quorum approves at the threshold; one rejection vetoes", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": ["three DISTINCT users each holding one of the approver positions"], + "fixtures": { + "app": "showcase", + "requires": ["a quorum (minApprovals: 2) request whose three position approvers resolve to three distinct users"], + "knownGaps": [ + "showcase admin holds manager+finance+legal, so the slate collapses to one person and the runtime clamps 2-of-3 to 1-of-1 — M-of-N is not demonstrable on stock seeds (#3358); needs a dedicated fixture or seed change" + ] + }, + "blocked": { "by": "fixture", "ref": "#3358 (quorum slate collapses onto the admin — showcase design call pending)" }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "runnable today (the clamp contrast): GET the seeded showcase_committee_quorum request on EXP-DEMO (behavior quorum, minApprovals 2 over manager/finance/legal — all resolving to the admin); record the collapsed pending slate", + "approve it ONCE as the admin and re-read: it finalizes immediately — the documented runtime clamp (minApprovals can never exceed the resolvable approver count), recorded as the CONTRAST, never as M-of-N proof", + "once the three-distinct-users fixture exists: as approver 1, POST /api/v1/approvals/requests/:id/approve — re-read: status STILL pending (1 of 2), approver 1 dropped from the slate", + "as approver 2, approve — re-read: status=approved, remaining pending task for approver 3 closed", + "GET /:id/actions — exactly two approve rows from two distinct actors", + "on a SECOND quorum request: as any single approver, POST /reject — re-read immediately", + "screenshot the drawer's M-of-N progress at each stage alongside the API reads" + ], + "acceptance": [ + { + "clause": "runnable today — clamp contrast: the stock EXP-DEMO slate collapses to the single admin and ONE approval finalizes the request (this is the clamp working, and the proof the fixture gap is real)", + "oracle": "api", + "verify": "initial read shows a single-user pending slate despite three declared position approvers; one approve flips status to approved; the actions read shows exactly one approval row", + "evidence": "before/after reads + actions read" + }, + { + "clause": "the request approves exactly when minApprovals DISTINCT approvals are recorded — not before, not after", + "oracle": "api", + "verify": "status transitions pending→pending→approved across the two approvals; approval action rows count 2 distinct actors; after approval 1 the slate lists the two remaining holders", + "evidence": "API reads after each decision" + }, + { + "clause": "a single rejection vetoes even with quorum-1 approvals already recorded", + "oracle": "api", + "verify": "on the second request: status flips to rejected on the first reject; no further tasks remain actionable; the flow run resumes down its reject edge", + "evidence": "API read after the reject + run read" + }, + { + "clause": "the third approver's pending task is closed by finalization, not left dangling", + "oracle": "api", + "verify": "after quorum is met, the request no longer lists approver 3 in pending_approvers and their inbox 待我审批 count drops", + "evidence": "request read + inbox count" + }, + { + "clause": "the drawer's M-of-N tally is server-computed and matches the API at each stage", + "oracle": "screenshot", + "verify": "progress screenshot after each decision alongside the paired API read", + "evidence": "screenshots + reads" + } + ], + "negative": [ + "a second approval by the SAME user must not count twice toward the quorum — a request that finalizes on two approvals from one actor is a FAIL of the distinctness the tally claims", + "the clamp contrast must be filed as evidence of the fixture gap, never ticked as M-of-N passing — a run record marking this item pass on stock seeds is itself a FAIL of protocol" + ], + "traps": ["seed-data-thin", "wrong-persona"], + "source": [ + "#3358 §1", + "examples/app-showcase/src/automation/flows/index.ts (CommitteeQuorumFlow, #3266)", + "packages/spec/src/automation/approval.zod.ts (behavior 'quorum' + minApprovals clamp: 'Clamped at runtime so it can never exceed the resolvable approver count')", + "examples/app-showcase/src/security/seed-approval-demo.ts (EXP-DEMO launch + admin position grants)" + ], + "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" } + ] + }, + { + "id": "approvals.inbox-metadata-actions", + "title": "Inbox actions are metadata-driven and gated by the viewer's relationship", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": ["approver who is NOT the submitter", "same user viewing a request they submitted"], + "fixtures": { + "app": "showcase", + "requires": [ + "requests with a real (non-null) submitter — seeded since #3411 (invoice submitted by admin, others by a no-position persona)", + "seed-approval-demo.ts: the invoice dual sign-off request is submitted BY the admin (their own request, so 我发起的 is non-empty), while EXP-2001 / EXP-DEMO are submitted by Mei Phone (other-submitter requests the admin approves)" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "other-submitter case: open the EXP-DEMO request from the 待我审批 tab of /system/approvals; screenshot, then read the rendered action set from the drawer DOM", + "own-request case: open the invoice request from the 我发起的 tab; screenshot, then read its action set", + "GET /api/v1/approvals/requests/:id for both and record the server-computed viewer flags (can_act, is_submitter) and status", + "cross-check each rendered action against the declared metadata actions on sys_approval_request (visibility expressions gate on record.viewer.can_act / record.viewer.is_submitter / record.status)", + "spot-check an approver action: approve EXP-DEMO from the drawer; capture the POST and re-read the request", + "spot-check a submitter action: send a reminder on the invoice request; capture POST /api/v1/approvals/requests/:id/remind", + "GET /:id/actions on both requests and record the appended timeline rows" + ], + "acceptance": [ + { + "clause": "approver-side actions (approve/reject/reassign/send-back/request-info) render from declared metadata on the other-submitter request", + "oracle": "dom", + "verify": "after confirming render via screenshot, read the action buttons from the drawer DOM and match the declared action set", + "evidence": "screenshot + DOM action list" + }, + { + "clause": "submitter-side actions (send-reminder, recall) appear ONLY on the viewer's own request", + "oracle": "dom", + "verify": "own-request drawer shows the two extra actions; other-submitter drawer does not", + "evidence": "side-by-side action lists for the two requests" + }, + { + "clause": "the rendered gating mirrors the server's viewer flags, which are computed on the request read — not a client heuristic", + "oracle": "api", + "verify": "for both requests, the API's viewer flags (can_act / is_submitter) predict exactly which action groups rendered; declared visibility expressions on sys_approval_request gate on those flags plus status", + "evidence": "request reads + the rendered sets" + }, + { + "clause": "each spot-checked action executes its REST route and the state change round-trips", + "oracle": "network", + "verify": "the approve click POSTs /api/v1/approvals/requests/:id/approve and the re-read reflects the decision; the remind click POSTs /:id/remind and stays status=pending", + "evidence": "network traces + re-reads" + }, + { + "clause": "every executed action appends a timeline row naming actor and action kind", + "oracle": "api", + "verify": "GET /:id/actions before/after each spot-check: exactly one new row per action, with the acting user", + "evidence": "before/after actions reads" + } + ], + "negative": [ + "an action button rendered for a viewer whose server flags deny it (can_act=false rendering approve) is a FAIL even if the click would 403 — the declared visibility is the contract being tested, not the eventual rejection" + ], + "traps": ["automation-input", "hydration-race"], + "source": [ + "#3358 §1", "#3411", + "packages/plugins/plugin-approvals/src/sys-approval-request.object.ts (declared actions + viewer-flag visibility expressions)", + "packages/rest/src/rest-route-ledger.ts (the approvals action route family)", + "objectui apps/console/src/pages/system/ApprovalsInboxPage.tsx (server-declared actions rendered; 待我审批 / 我发起的 tabs)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from the #3358 evidence run (7-action table proven after #3411 stamped real submitters)", "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" } + ] + }, + { + "id": "approvals.viewer-gating-submitter-side", + "title": "A submitter who is not an approver sees no approver buttons", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": ["submitter holding NO approver position on their own pending request"], + "fixtures": { + "app": "showcase", + "requires": ["one pending request routed to a position its submitter does not hold"], + "knownGaps": [ + "stock seeds route every request to positions the admin holds, so the admin is an approver on all of them (#3358) — needs one request addressed away from the signed-in persona", + "the natural persona exists since #3411 — Mei Phone (usr_showcase_phone_demo) submits EXP-2001/EXP-DEMO and holds no approver position — but she is a sys_user row only: signing in as her needs a better-auth account provisioned at runtime (seed-approval-demo.ts)" + ] + }, + "blocked": { "by": "fixture", "ref": "#3358 (needs a request routed to a position the viewing submitter does not hold)" }, + "steps": [ + "provision a sign-in account for the no-position submitter persona (Mei Phone) or an equivalent fixture user; sign in as that persona", + "open /system/approvals — the 我发起的 tab must list her pending EXP-2001 request", + "open the request drawer; screenshot the rendered action set", + "GET /api/v1/approvals/requests/:id as this persona and record the viewer flags (can_act, is_submitter) and pending_approvers", + "forge the gate: as this persona POST /api/v1/approvals/requests/:id/approve directly with an approve body", + "re-read the request and its /actions timeline after the forged call" + ], + "acceptance": [ + { + "clause": "the drawer omits approve/reject for the submitter while still rendering the submitter-side affordances (remind / recall)", + "oracle": "screenshot", + "verify": "drawer screenshot as the submitter: no approver decision buttons; remind/recall present on the own-request", + "evidence": "drawer screenshot" + }, + { + "clause": "the server's viewer flags say why: can_act=false, is_submitter=true — the rendered absence is metadata, not a client guess", + "oracle": "api", + "verify": "the request read as this persona carries can_act=false and is_submitter=true, and pending_approvers does not contain her id", + "evidence": "request read" + }, + { + "clause": "the gate is server-side: a forged direct POST of the decision route as the submitter is rejected (ADR-0057 D10 — UI absence alone is a client courtesy)", + "oracle": "api", + "verify": "the forged approve answers FORBIDDEN (403-mapped); test BOTH sides — the entitled approver's decision on the same request succeeds", + "evidence": "the rejected call + the entitled approver's accepted call" + }, + { + "clause": "the forged call left no trace: status, tallies, and the actions timeline are byte-identical after it", + "oracle": "api", + "verify": "before/after reads of the request and /:id/actions — no new action row, no tally movement", + "evidence": "before/after reads" + } + ], + "negative": [ + "a forged submitter decision that answers 2xx, appends an action row, or moves a tally is a FAIL — silent server acceptance is the exact failure this gate exists to prevent" + ], + "traps": ["hydration-race", "wrong-persona"], + "source": [ + "#3358 §1", + "ADR-0057 D10 (server is the authoritative visibility gate)", + "examples/app-showcase/src/security/seed-approval-demo.ts (Mei Phone: 'a clean submitter — a requester who is never also one of her own approvers')" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358; upgraded the oracle from DOM-only to both-sides (UI absence + server rejection)", "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" } + ] + }, + { + "id": "approvals.notification-deep-link", + "title": "An approval notification deep-links straight into the request drawer", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": ["any pending approver"], + "fixtures": { + "app": "showcase", + "requires": ["at least one pending request whose open notified the signed-in approver (the three seeded demo requests suffice)"] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin (a pending approver on the seeded requests)", + "read the bell notification for a pending approval and record its actionUrl — it must carry /system/approvals?request= (#2678 P1.5)", + "cold-load that exact URL in a fresh page (no prior navigation); wait for settle; screenshot with the URL visible", + "GET /api/v1/approvals/requests/:id and cross-check the drawer's request identity and status against the read", + "repeat the cold load a second time on a fresh page (the hydration-race counter)", + "negative probe: cold-load /system/approvals?request= and screenshot the result" + ], + "acceptance": [ + { + "clause": "the notification's actionUrl carries the ?request= deep link for the exact pending request", + "oracle": "api", + "verify": "read the notification/inbox row: its actionUrl contains /system/approvals?request= with the request id that the approvals API lists as pending for this user", + "evidence": "notification row read" + }, + { + "clause": "the ?request= URL opens the request drawer directly on a cold load — verified twice on fresh loads", + "oracle": "screenshot", + "verify": "fresh navigation renders the drawer for that exact request both times (verify twice on fresh loads)", + "evidence": "two screenshots with the URL visible" + }, + { + "clause": "the drawer shows the SAME request the API returns for that id — identity, status, pending slate", + "oracle": "api", + "verify": "field-match the drawer against GET /api/v1/approvals/requests/:id — a drawer that opened on the wrong request also 'renders a drawer'", + "evidence": "request read + drawer screenshot" + }, + { + "clause": "an unknown ?request id degrades to the inbox without a drawer (or an explicit not-found state) — never someone else's request", + "oracle": "screenshot", + "verify": "the nonexistent-id load renders the inbox list with no drawer or a not-found notice; assert no drawer carrying a different request id", + "evidence": "screenshot of the degraded state" + } + ], + "negative": [ + "a deep link that lands on the inbox with the WRONG request (or none) selected while the run records 'drawer rendered' is a FAIL — the clause is the exact request id, not the drawer's existence" + ], + "traps": ["hydration-race", "shared-browser-tab"], + "source": [ + "#3358 §1", + "objectui apps/console/src/pages/system/ApprovalsInboxPage.tsx (#2678 P1.5 — 'notifications carry /system/approvals?request=')" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358 (verified twice on fresh loads)", "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" } + ] + }, + { + "id": "approvals.decision-action-matrix", + "title": "Every approval action executes its REST route and produces the expected state transition plus a timeline entry", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["dev admin (pending approver on the seeded requests; submitter of the invoice request)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the three seeded demo requests (invoice unanimous / EXP-DEMO quorum / EXP-2001 per-group) plus fresh showcase_budget_approval requests raised on demand by PATCHing a showcase_project budget above 100000 (budget != previous.budget)", + "showcase_budget_approval's manager step declares the ADR-0044 revise loop (maxRevisions: 2) and lockRecord: false; its exec step (budget > 500000) declares NO revise edge and lockRecord: true — both sides of two gates in one flow" + ] + }, + "variants": ["approve", "reject", "reassign", "revise (send-back)", "resubmit", "recall", "remind", "request-info", "comment"], + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "approve: POST /api/v1/approvals/requests/:id/approve {comment} on the EXP-DEMO request; re-read status + run", + "reject: raise a fresh budget-approval request (PATCH a project budget to e.g. 200000); POST /:id/reject {comment}; re-read — the flow resumes down its reject edge", + "revise: raise another budget-approval request; POST /:id/revise {comment} as the pending manager — re-read status; GET the flow run (parked at the approval_revise node 'wait_revision')", + "resubmit: POST /:id/resubmit {comment} as the submitter — re-read: pending again, round 2, fresh slate; then drive revise→resubmit→revise again and attempt a THIRD revise (maxRevisions: 2 — it must auto-reject)", + "recall: raise one more pending request; POST /:id/recall as the submitter; re-read", + "reassign: on a pending request POST /:id/reassign {to: , comment}; re-read pending_approvers", + "remind: POST /:id/remind on the admin-submitted invoice request; then POST it again immediately (throttle probe)", + "request-info + comment: POST /:id/request-info {comment} and /:id/comment {comment} on a pending request", + "after EVERY action above: GET /:id and GET /:id/actions; record the status transition and the appended timeline row", + "lock contrast: while a request from exec_review (lockRecord: true) is pending, PATCH the project record — refused; while parked at manager_review (lockRecord: false) the same PATCH succeeds", + "exec no-revise gate: drive a >500000 budget to exec_review and POST /:id/revise — expect a clear refusal" + ], + "acceptance": [ + { + "clause": "per-variant: each of the nine actions executes its POST /api/v1/approvals/requests/:id/ route and appends exactly one timeline action row naming the actor and the action kind — every variant individually verified", + "oracle": "api", + "verify": "per-variant table: route called, response status, the new /:id/actions row; a variant with no row (or two) fails that variant", + "evidence": "per-variant request/response + actions reads" + }, + { + "clause": "decisions finalize per the APPROVAL_STATUSES lifecycle: approve → approved, reject → rejected, and the parked flow run resumes down the matching branch label", + "oracle": "api", + "verify": "request status transitions pending→approved / pending→rejected; the owning run transitions paused→completed with the approve/reject edge taken", + "evidence": "before/after request + run reads" + }, + { + "clause": "revise (send-back) moves pending→returned and parks the run at the service-owned approval_revise node; resubmit moves returned→pending as round 2 with a fresh approver slate", + "oracle": "api", + "verify": "after revise: status=returned, run paused at 'wait_revision'; after resubmit: status=pending, slate repopulated; the resubmit is refused for anyone but the submitter", + "evidence": "reads after each move" + }, + { + "clause": "the maxRevisions guard holds: the third send-back auto-rejects instead of looping forever", + "oracle": "api", + "verify": "after two revise/resubmit rounds, the next revise finalizes the request rejected (maxRevisions: 2 on manager_review)", + "evidence": "the third-revise response + final read" + }, + { + "clause": "recall is submitter-only and moves pending→recalled; the approver's task disappears from the inbox", + "oracle": "api", + "verify": "recall as submitter succeeds (status=recalled); recall attempted by a non-submitter is FORBIDDEN; 待我审批 no longer lists it", + "evidence": "reads + the denied call" + }, + { + "clause": "non-finalizing actions mutate only what they own: reassign swaps the slate slot to the target user; remind/request-info/comment leave status=pending and touch nothing but the timeline", + "oracle": "api", + "verify": "after reassign: pending_approvers contains the target and not the source; after remind/request-info/comment: status and tallies byte-identical, one new timeline row each", + "evidence": "before/after reads per action" + }, + { + "clause": "lockRecord is enforced on both sides: the exec step (lockRecord: true) refuses record edits while pending; the manager step (lockRecord: false) permits them", + "oracle": "api", + "verify": "PATCH the project while each step is pending: refused under exec_review, accepted under manager_review (the objectui#2902 pair)", + "evidence": "the two PATCH responses" + } + ], + "negative": [ + "send-back at exec_review reporting success is a FAIL — the step declares no revise edge and the service must refuse with a clear error", + "an immediately repeated remind must be throttled ('a reminder was sent recently') — a silent second success is a FAIL", + "any action POST accepted from an actor the service should deny (wrong relationship to the request) is a FAIL even when the state happens to end up plausible" + ], + "traps": ["automation-input", "wrong-persona"], + "source": [ + "#3358 §1 (the action-table evidence this matrix grounds in)", + "packages/rest/src/rest-route-ledger.ts (approve/reject/recall/revise/resubmit/reassign/remind/request-info/comment + GET /:id/actions)", + "packages/rest/src/rest-server.ts (flowMoveRoute: revise=pending approver, resubmit=submitter; threadRoute access per action; recall submitter-only)", + "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)" + ], + "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" } + ] + }, + { + "id": "approvals.decision-only-via-service", + "title": "An approval-parked run cannot be resumed through the generic automation route — decisions go through the approvals service only", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["dev admin (holds finance + legal, so the seeded unanimous request is decidable)"], + "fixtures": { + "app": "showcase", + "requires": ["showcase_invoice_signoff parked at its aggregating approval node (the seeded invoice request, or a fresh one via PATCHing an invoice draft→sent)"] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "locate the paused run: GET /api/v1/automation/showcase_invoice_signoff/runs — status=paused at the dual_signoff approval node; note the runId and the matching approval request id", + "forge the generic resume: POST /api/v1/automation/showcase_invoice_signoff/runs/:runId/resume with {} and again with {branchLabel: 'approve'}", + "re-read the run and the approval request after each forged attempt", + "decide properly: POST /api/v1/approvals/requests/:id/approve until the unanimous slate is satisfied; re-read request and run", + "contrast (the other side of the gate): pause showcase_reassign_wizard at its screen node via the Tasks row action, then resume THAT run through the same generic route with valid inputs" + ], + "acceptance": [ + { + "clause": "the generic resume route answers 403 for a run parked on an approval node (resumeAuthority: 'service', #3801) — with or without a branchLabel in the body", + "oracle": "api", + "verify": "both forged POSTs answer 403; neither 2xx nor a 404 that would mask the gate (the run exists)", + "evidence": "the two rejected responses" + }, + { + "clause": "the forged attempts change nothing: the run stays paused and the request records no decision", + "oracle": "api", + "verify": "run re-read: status=paused at dual_signoff; GET /:id/actions: no new action row after the forged calls", + "evidence": "before/after run + actions reads" + }, + { + "clause": "the ApprovalService decision is the door that works: satisfying the slate resumes the run down the approve edge to completion", + "oracle": "api", + "verify": "after the approvals-API decisions: request status=approved; the run transitions paused→completed with the notify_cleared step executed", + "evidence": "final request + run reads" + }, + { + "clause": "the gate is node-scoped, not route-dead: a run paused at a SCREEN node resumes fine through the same generic route", + "oracle": "api", + "verify": "the showcase_reassign_wizard resume with valid inputs answers 2xx and completes its run — proving the 403 above is the approval-node gate, not a broken route", + "evidence": "the accepted screen resume + its run read" + } + ], + "negative": [ + "a generic resume that answers 2xx on an approval pause is a FAIL of the #3801 gate — fail-open by omission is the exact regression this pins, and a passing screen-flow resume is REQUIRED alongside it to prove the test hit the gate rather than a dead route" + ], + "traps": ["dispatcher-vs-hono-route"], + "automated": { "kind": "test", "ref": "packages/services/service-automation/src/resume-authority-gate.test.ts" }, + "source": [ + "packages/runtime/src/route-ledger.ts (the resume route's #3801 note: resumeAuthority 'service' → 403, decisions via ApprovalService.decide)", + "examples/app-showcase/src/automation/flows/index.ts (InvoiceDualSignoffFlow — documents the exact 403 + approvals-API sequence)", + "packages/spec/src/automation/approval.zod.ts (APPROVAL_NODE_TYPE; ADR-0039 Track A aggregating node)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — pins the #3801 resume-authority gate as a both-sides item (approval 403 + screen 2xx contrast)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "approvals.dynamic-approver-routing", + "title": "A decision's typed outputs route the next stage: expression approvers resolve from the previous decision at node entry", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["dev admin (org-membership owner — the stage-1 approver of the dynamic-approval demo)"], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_dynamic_approval (#3447 P2): stage 1 routes to org_membership_level 'owner' with a REQUIRED decisionOutput next_reviewers (typed user multi picker); stage 2 resolves an expression approver over vars from that output, with onEmptyApprovers: 'fail'", + "trigger: retitle a showcase_announcement (an otherwise approval-free object, so this demo never collides with the expense/invoice/project approval dedupe)" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "PATCH a showcase_announcement's title over /api/v1/data — showcase_dynamic_approval opens its stage-1 Lead Review request routed at the org owner", + "GET /api/v1/approvals/requests/:id — record status and the declared decisionOutputs contract on the request/node", + "open the request drawer; screenshot the decision dialog's next_reviewers control — it must be a sys_user MULTI-SELECT picker, not a free-text box", + "gate probe: attempt to approve WITHOUT filling next_reviewers; record the refusal", + "approve WITH next_reviewers=[the admin] via the dialog; capture the decision POST body", + "GET the requests list again — the stage-2 co-sign request must now exist; read its pending_approvers", + "GET /api/v1/automation/showcase_dynamic_approval/runs/:runId — the run's variables carry the stage-1 outputs; decide stage 2 and confirm the run completes" + ], + "acceptance": [ + { + "clause": "the stage-1 decision dialog renders the TYPED control the metadata declares: a required sys_user multi-select for next_reviewers", + "oracle": "screenshot", + "verify": "dialog screenshot shows a record picker (multi), not free text — the #3508 degraded-to-text failure is the counter-case", + "evidence": "decision-dialog screenshot" + }, + { + "clause": "approve without the required output is REFUSED and the request stays pending", + "oracle": "api", + "verify": "the outputless approve is rejected (required decision output enforced on approve, objectui#2955); request re-reads status=pending with no new approval action row", + "evidence": "rejected attempt + re-read" + }, + { + "clause": "the accepted decision stores its outputs and stage 2's expression approver resolves EXACTLY the picked users at node entry", + "oracle": "api", + "verify": "the stage-2 request's pending_approvers equal the user ids submitted in next_reviewers — no more, no fewer", + "evidence": "decision POST body + stage-2 request read" + }, + { + "clause": "the decision outputs ride the flow run's variables (the vars.* the stage-2 expression reads), observable on the run detail", + "oracle": "api", + "verify": "run read between the stages: the stage-1 outputs present in the run variables snapshot", + "evidence": "run-detail read" + }, + { + "clause": "deciding stage 2 completes the run end to end", + "oracle": "api", + "verify": "after the co-sign decision: request finalized and the run transitions paused→completed", + "evidence": "final request + run reads" + } + ], + "negative": [ + "an outputless approve that succeeds is a FAIL — stage 2 declares onEmptyApprovers: 'fail', so a skipped required output would kill the run downstream; the enforcement must be at the decision, not the crash site" + ], + "traps": ["automation-input", "stale-console-bundle"], + "source": [ + "examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts (#3447 P2)", + "packages/spec/src/automation/approval.zod.ts (DecisionOutputDefSchema — typed pickers, required-to-approve objectui#2955; expression approvers + resolveAs)", + "examples/app-showcase/src/automation/flows/approver-bindings.flow.ts (#3508 — the degraded-to-free-text failure the typed control fixes)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — covers the #3447 dynamic-routing chain (typed decision outputs → vars → expression approvers) with the required-output gate", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "approvals.ooo-delegation-reroute", + "title": "An active out-of-office delegation reroutes an individually-routed approver to the delegate; expiring the window hands the slot back", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["dev admin (the DELEGATE B — signed in, so they can actually decide the rerouted request)", "a routable non-admin delegator A (sys_user row, e.g. Mei Phone usr_showcase_phone_demo)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a flow with an INDIVIDUALLY-routed approver — OOO delegation only applies to type user / field / manager (ApprovalService.expandApprovers), NOT position. No ACTIVE seeded showcase flow routes individually (they all route by position), so author a scratch autolaunched flow in a WRITABLE package with a single approval node config.approvers=[{type:'user', value:''}], shaped after examples/app-showcase/src/automation/flows/approver-bindings.flow.ts (the record-backed approver specimen)", + "delegator A = a routable sys_user row that is NOT the signed-in admin (Mei Phone usr_showcase_phone_demo); delegate B = the dev admin" + ], + "knownGaps": [ + "the delegate must be the SIGNED-IN admin (B) so the rerouted request is decidable without provisioning a second better-auth account — the same sign-in gap approvals.per-group-signoff records for Ada; routing the scratch flow at a non-admin A and delegating A→admin sidesteps it", + "authoring the scratch flow needs a writable/scratch package — the showcase ships read-only" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin (the delegate B)", + "in a writable/scratch package author + register (POST /api/v1/automation) an autolaunched flow with a single approval node whose approvers=[{type:'user', value:''}], behavior first_response", + "baseline (no delegation): trigger the flow (POST /api/v1/automation//trigger); GET /api/v1/approvals/requests?status=pending, find the new request, GET /:id — pending_approvers must be [A]; GET /:id/actions shows NO ooo_substitute row", + "create an ACTIVE delegation: POST /api/v1/data/sys_approval_delegation {delegator_id:'', delegate_id:'', valid_from:, valid_until:, reason:'Annual leave'}", + "trigger the flow AGAIN; GET the new request /:id — pending_approvers must now be [B], not [A]; GET /:id/actions — one row action='ooo_substitute' whose comment names 'A → B'", + "read the delegate's inbox/notifications — a topic approval.ooo_substituted notification addressed to B, actionUrl /system/approvals (#1322 M4)", + "decide as B: POST /api/v1/approvals/requests/:id/approve; GET /:id/actions — the approve row's actor is B (the delegate acts under their OWN identity — nothing impersonated as A)", + "expire the window: PATCH the delegation valid_until to a past instant (or DELETE it); trigger the flow ONCE more; GET the newest request /:id — pending_approvers is [A] again and its /actions carries NO ooo_substitute row" + ], + "acceptance": [ + { + "clause": "baseline: with no active delegation the individually-routed request resolves pending_approvers = [A]", + "oracle": "api", + "verify": "GET /:id on the pre-delegation request: pending_approvers == [''] and /:id/actions has no action='ooo_substitute' row", + "evidence": "baseline request + actions reads" + }, + { + "clause": "an active A→B delegation reroutes the slot: a fresh request resolves pending_approvers = [B] (not A) and records the substitution on the audit trail", + "oracle": "api", + "verify": "GET /:id after the delegation exists: pending_approvers == ['']; GET /:id/actions carries exactly one action='ooo_substitute' row (actor_id null — a system action) whose comment reads ''", + "evidence": "post-delegation request + actions reads" + }, + { + "clause": "the substitution notifies the delegate (M4): B receives an approval.ooo_substituted inbox notification deep-linking the approvals inbox", + "oracle": "api", + "verify": "the delegate's notification/inbox read contains a topic approval.ooo_substituted row for this request id with actionUrl containing /system/approvals", + "evidence": "notification read" + }, + { + "clause": "the delegate decides under their OWN identity — the audit stays honest, nothing is impersonated as the delegator", + "oracle": "api", + "verify": "B's /approve succeeds and finalizes the request; the recorded approve action's actor_id is B, never A (the delegate becomes a real pending approver, ApprovalService docstring)", + "evidence": "decision POST + the approve action row" + }, + { + "clause": "the window is enforced at RESOLUTION time (isGrantActive, ADR-0091 D2), not by a job: after the window expires a fresh request routes back to A with no substitution", + "oracle": "api", + "verify": "with valid_until in the past, GET /:id on the newest request: pending_approvers == [''] and /:id/actions has no ooo_substitute row — B has lost the power the instant the window closed", + "evidence": "post-expiry request + actions reads" + } + ], + "negative": [ + "an approval action attributed to A while B was the one who clicked is a FAIL — the delegate acts under their own identity and the audit must not launder the decision back onto the out-of-office user", + "a request that still reroutes to B after the window has expired (or before valid_from) is a FAIL — validity is a half-open [from, until) window enforced at resolution, never a background job that could lag", + "a delegation on a POSITION-routed slot that reroutes is out of contract — OOO applies only to individually-routed (user/field/manager) approvers; a per-group/position node must be unaffected" + ], + "traps": ["wrong-persona", "seed-data-thin"], + "source": [ + "packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts (#1322 M1 — self-service OOO rule, half-open UTC window, resolution-time enforcement)", + "packages/plugins/plugin-approvals/src/approval-service.ts (applyOooDelegation + lookupActiveDelegation — individually-routed only; M4 ooo_substitute audit row + approval.ooo_substituted / approval.ooo_skipped notifications)", + "@objectstack/core isGrantActive (ADR-0091 D2 half-open validity predicate)", + "examples/app-showcase/src/automation/flows/approver-bindings.flow.ts (the {type:'user'|'manager'|'field'} approver specimens the scratch flow is shaped after)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — pins the #1322 OOO delegation reroute: active A→B window reroutes an individually-routed slot to B (audited + notified, decided under B's own identity); expiry hands it back to A at resolution time", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "approvals.record-page-decisions", + "title": "Approve/Reject render in the record header; Reject fires after ONE dialog; a locking approval hides inline edit", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["dev admin (holds the manager position, so showcase_budget_approval's manager_review routes to them — a pending approver standing on the record page)"], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_budget_approval raised on a showcase_project by PATCHing budget above 100000 (budget != previous.budget) — parks at manager_review (approvers position 'manager' = the admin), lockRecord:false; driving budget above 500000 reaches exec_review, lockRecord:true (the locking contrast, objectui#2902)", + "the record page surface: objectui packages/app-shell RecordDetailView + useRecordApprovals + buildApprovalDecisionActions" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "PATCH a showcase_project budget to e.g. 200000 over /api/v1/data — showcase_budget_approval opens and parks at manager_review (routed to manager = admin), lockRecord:false", + "open that project's record page (/apps/showcase_app/showcase_project/:id); screenshot the header — Approve and Reject must render in the primary slot (buildApprovalDecisionActions: Approve variant 'primary' order -100, Reject variant 'destructive' order -99, both locations ['record_header'])", + "because manager_review is lockRecord:false, confirm inline-edit affordances remain live on this record (the editable contrast)", + "click Reject: assert exactly ONE dialog opens — the param dialog carrying the comment textarea inline, titled by the Reject label with the confirm question as its description; there is NO separate confirm-then-comment second dialog (objectui#3126)", + "fill the comment and Confirm; capture POST /api/v1/approvals/requests/:id/reject and re-read the request (status=rejected)", + "approve path: raise another budget-approval request; on its record page click Approve → one dialog with an optional comment → Confirm → POST /:id/approve → re-read (status=approved)", + "lock contrast: drive a >500000 budget to exec_review (lockRecord:true) on a project record page; screenshot — the inline-edit affordances are HIDDEN (recordLockedByApproval → canEdit suppressed)", + "both-sides gate: open a project record page whose pending approval routes AWAY from the admin (or has none) — the header offers NO Approve/Reject (approvals.canDecide false)" + ], + "acceptance": [ + { + "clause": "Approve and Reject render in the record header primary slot for the pending approver", + "oracle": "screenshot", + "verify": "after the header renders, screenshot shows Approve (primary) ahead of app record_header actions and Reject (destructive) — the strongly-negative order floats the decision buttons into the primary slot (buildApprovalDecisionActions #2670/objectui#2339)", + "evidence": "record-header screenshot" + }, + { + "clause": "Reject fires after exactly ONE dialog: the comment param is collected INLINE in that single dialog and Confirm POSTs the reject — no second, unexpected comment dialog", + "oracle": "network", + "verify": "one param dialog (no chained confirm dialog) precedes exactly one POST /api/v1/approvals/requests/:id/reject; the objectui#3126 double-dialog bug (confirm then a second comment dialog, decision silently not sent) must not reproduce", + "evidence": "screenshot of the single dialog + the reject network trace" + }, + { + "clause": "the decision POSTs the approvals route and the request re-reads with the new status", + "oracle": "api", + "verify": "reject → GET /:id status=rejected; approve → status=approved; the record header collected the comment (rides actionParams, not the dead collectParams #2955) and it round-trips onto the action row", + "evidence": "before/after request reads + the decision action row" + }, + { + "clause": "a LOCKING pending approval hides the record's inline-edit affordances; a non-locking one keeps them", + "oracle": "screenshot", + "verify": "under exec_review (lockRecord:true) the inline-edit affordances are absent (recordLockedByApproval(pendingRequest) → canEdit false, objectui#2902); under manager_review (lockRecord:false) they remain — both sides, on the same flow", + "evidence": "two record-page screenshots (locked vs editable)" + }, + { + "clause": "the decision buttons are gated by the viewer's approver relationship — a record whose pending approval does not route to the viewer offers neither Approve nor Reject", + "oracle": "dom", + "verify": "after confirming render via screenshot, the header DOM has no approve_request/reject_request actions when approvals.canDecide is false (the current user is not in pending_approvers)", + "evidence": "screenshot + header action DOM list" + } + ], + "negative": [ + "a Reject that opens a SECOND dialog (a confirm dialog followed by a separate comment dialog) and silently no-ops after the first Confirm is a FAIL — the objectui#3126 regression: the param dialog IS the confirmation, and nothing posts until its own Confirm", + "an inline-edit affordance offered while a lockRecord:true approval is pending is a FAIL — the server would reject the save RECORD_LOCKED, and offering the edit is exactly the objectui#2902 mislabel", + "Approve/Reject rendered on the header for a viewer who is not a pending approver (canDecide false) is a FAIL even if the eventual POST would 403" + ], + "traps": ["automation-input", "hydration-race"], + "source": [ + "objectui packages/app-shell/src/views/RecordDetailView.tsx (buildApprovalDecisionActions — record_header Approve/Reject, single-dialog Reject #3126, actionParams comment #2955; approvalLocked/canEdit gating #2902)", + "objectui packages/app-shell/src/hooks/useRecordApprovals.ts (recordLockedByApproval, canDecide, /approvals/requests decide routes)", + "objectui packages/app-shell/src/views/RecordDetailView.approvalDecisionActions.test.tsx (the param-contract pin)", + "examples/app-showcase/src/automation/flows/index.ts (BudgetApprovalFlow — manager_review lockRecord:false / exec_review lockRecord:true on showcase_project)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — the record-page approval surface (distinct from the inbox-drawer items): header Approve/Reject, one-dialog Reject (#3126), decision round-trip, and the lockRecord-driven inline-edit hide (#2902)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "approvals.sla-escalation", + "title": "A node's SLA escalation fires once past its timeout — the declared action runs and an escalate timeline row lands", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["dev admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "a scratch autolaunched flow in a WRITABLE package with an approval node carrying config.escalation {enabled:true, timeoutHours, action, escalateTo, notifySubmitter} (ApprovalEscalationSchema, packages/spec/src/automation/approval.zod.ts)", + "clock control OR fractional-hour support: timeoutHours has a min of 1 and the escalation sweep (ApprovalService.runEscalations, ESCALATION_JOB_NAME, ADR-0042) runs on an interval against this.clock.now(), so driving a request PAST its deadline needs an injected/advanced clock or a direct runEscalations() call with a clock whose now() is beyond slaDueAt = created_at + timeoutHours" + ], + "knownGaps": [ + "hour-granular SLAs are not drivable on stock fixtures in a single session: the minimum timeoutHours is 1 and the sweep uses real time, so reaching the deadline requires a timing harness (clock injection / controllable runEscalations) that stock showcase does not provide — the timeout-dependent clauses (2/3/4) run only under that harness; clauses 1 and 5 are runnable today" + ] + }, + "blocked": { "by": "fixture", "ref": "hour-granular SLA needs a clock-control / runEscalations timing harness (timeoutHours min 1, sweep on real-time interval) — no stock-fixture way to advance past the deadline in-session" }, + "variants": ["reassign", "auto_approve", "auto_reject", "notify"], + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "in a writable package author + register a flow whose approval node config.escalation = {enabled:true, timeoutHours:1, action:'reassign', escalateTo:'', notifySubmitter:true}", + "trigger the flow; GET /api/v1/approvals/requests/:id — status=pending and the request carries sla_due_at = created_at + timeoutHours (the SLA is materialized on open)", + "[needs clock control] advance the clock past sla_due_at (inject a clock / drive ApprovalService.runEscalations() with a clock whose now() is beyond the deadline) and run one escalation sweep", + "GET /:id/actions — assert exactly one action='escalate' row (the audit-first idempotency marker, actor SLA_ACTOR_ID) whose comment names the action", + "assert the declared action's effect: reassign → pending_approvers swapped to the escalatees + an approval.escalated notification to them; auto_approve/auto_reject → request finalized approved/rejected and the owning run resumes; notify → an approval.sla_breached notification to the pending approvers", + "with notifySubmitter!==false, read the submitter's inbox — an approval.sla_breached notification addressed to them", + "idempotency: run the sweep a SECOND time — GET /:id/actions shows NO second escalate row (single-shot, marker-guarded)", + "author-negative: build a scratch flow whose escalation carries an unknown key (or the `timeout`/`sla` alias) and validate it" + ], + "acceptance": [ + { + "clause": "the node's declared SLA is materialized on the pending request: sla_due_at = created_at + timeoutHours", + "oracle": "api", + "verify": "GET /:id right after the request opens: sla_due_at equals created_at plus timeoutHours (slaDueAt, packages/plugins/plugin-approvals/src/approval-service.ts) — this half is runnable without clock control", + "evidence": "request read with sla_due_at" + }, + { + "clause": "past the deadline the sweep escalates exactly ONCE: one action='escalate' timeline row, and a re-run adds none", + "oracle": "api", + "verify": "after advancing past sla_due_at and sweeping, GET /:id/actions has exactly one action='escalate' row (actor SLA_ACTOR_ID); a second sweep adds no further escalate row (the audit row is the idempotency marker, written before any mutation)", + "evidence": "actions reads after the first and second sweeps" + }, + { + "clause": "per-variant: the declared escalation action fires — reassign swaps pending_approvers to the escalatees (+ approval.escalated notify), auto_approve/auto_reject finalizes the request and resumes the run, notify posts approval.sla_breached to the pending approvers", + "oracle": "api", + "verify": "for the authored action, cite the concrete effect: reassign → pending_approvers == escalatees; auto_approve → status=approved + run resumed; auto_reject → status=rejected + run resumed; notify → an approval.sla_breached inbox row for each pending approver (escalateRequest, approval-service.ts)", + "evidence": "request/run/notification reads per variant" + }, + { + "clause": "notifySubmitter is honored: with notifySubmitter!==false the original submitter is notified of the SLA breach", + "oracle": "api", + "verify": "the submitter's inbox carries an approval.sla_breached notification naming the escalation action taken", + "evidence": "submitter notification read" + }, + { + "clause": "the escalation config is strict at authoring: an unknown key (or a remappable alias like `timeout`/`sla`) is rejected/normalized at build, so a declared SLA can never silently no-op", + "oracle": "build", + "verify": "the unknown-key scratch flow fails validate with a located error naming the escalation surface (ApprovalEscalationSchema.strict, #4001 — 'until #4001 these were dropped silently')", + "evidence": "build/validate output" + } + ], + "negative": [ + "a request left pending past its sla_due_at with NO escalate row ever written is a FAIL — the #4001 'declared but never fired' shape the strict schema + sweep exist to close", + "a second escalate row on a re-run is a FAIL — escalation is single-shot, guarded by the audit marker", + "an escalation config that parses with an unknown key silently dropped is a FAIL — it means the author's SLA intent was discarded (pre-#4001 behavior)" + ], + "traps": ["seed-data-thin", "stale-dist"], + "source": [ + "packages/spec/src/automation/approval.zod.ts (ApprovalEscalationSchema — enabled/timeoutHours(min 1)/action(reassign|auto_approve|auto_reject|notify)/escalateTo/notifySubmitter, strict, #4001; carried on the approval node as config.escalation)", + "packages/plugins/plugin-approvals/src/approval-service.ts (runEscalations sweep + escalateRequest — audit-first escalate row, per-action effects, notifySubmitter; slaDueAt; ESCALATION_JOB_NAME ADR-0042; this.clock)", + "packages/plugins/plugin-approvals/src/sys-approval-request.object.ts (sla_due_at surfaced on the request)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — pins the ADR-0042 SLA escalation (declared action fires once past timeout + escalate timeline row); blocked on a clock-control timing harness (hour granularity), with the sla_due_at materialization and the strict-schema build clause runnable today", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/attachments-storage.json b/docs/qa/platform-checklist/areas/attachments-storage.json new file mode 100644 index 0000000000..8e503ca21e --- /dev/null +++ b/docs/qa/platform-checklist/areas/attachments-storage.json @@ -0,0 +1,581 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "attachments-storage", + "title": "Attachments & storage — presigned/chunked upload, signed-URL downloads, parent-derived access, sys_file lifecycle", + "items": [ + { + "id": "attachments-storage.presigned-upload-roundtrip", + "title": "Authenticated presigned upload → committed sys_file → signed-URL download round-trip; anonymous upload is 401", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai)", "anonymous (no bearer token)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the storage service booted with the local adapter (stock `objectstack dev` pairing) — routes mount at the default base /api/v1/storage" + ] + }, + "steps": [ + "as admin, POST /api/v1/storage/upload/presigned with { filename: 'qa-roundtrip.png', mimeType: 'image/png', size: , scope: 'attachments' } and capture { uploadUrl, method, fileId, downloadUrl }", + "read the fresh sys_file row (GET /api/v1/data/sys_file/ as admin): status must be 'pending', owner_id must equal the admin's user id (server-stamped from the session, storage-routes.ts)", + "PUT the file bytes to the returned uploadUrl (local driver: PUT /api/v1/storage/_local/raw/:token — an HMAC-token capability URL, followed opaquely exactly as an S3 presigned URL would be)", + "POST /api/v1/storage/upload/complete with { fileId } and re-read the sys_file row: status must now be 'committed'", + "GET /api/v1/storage/files//url with the admin bearer and capture the response envelope", + "GET the returned signed url and compare the served bytes to the uploaded payload", + "GET /api/v1/storage/files/ (no /url suffix) and capture the redirect — the stable browser door 302s to the same short-lived signed URL", + "repeat step 1 with NO Authorization header and capture the refusal" + ], + "acceptance": [ + { + "clause": "presign answers 200 with { uploadUrl, method, fileId, expiresIn, downloadUrl } and persists a status='pending' sys_file whose owner_id is the session user — a client-supplied owner never wins", + "oracle": "api", + "verify": "the presign response fields + a sys_file read showing status 'pending' and owner_id == the admin's sys_user id", + "evidence": "presign response + sys_file read" + }, + { + "clause": "complete flips the sys_file status pending → committed", + "oracle": "api", + "verify": "before/after sys_file reads around POST /upload/complete", + "evidence": "the two reads" + }, + { + "clause": "GET /files/:fileId/url answers the declared envelope { success: true, data: { url } } (the bare { url } retired in #3689) and the url serves back the exact uploaded bytes", + "oracle": "api", + "verify": "envelope shape check + byte-for-byte comparison of the downloaded body against the uploaded payload", + "evidence": "the /url response + a hash of both payloads" + }, + { + "clause": "GET /files/:fileId (browser capability door) 302-redirects to the same signed URL — this is the value objectql stamps into file/image field payloads, so it must work verbatim in an /", + "oracle": "network", + "verify": "the response is a 302 whose Location resolves to the bytes", + "evidence": "the redirect trace" + }, + { + "clause": "anonymous presigned upload is refused 401 AUTH_REQUIRED — the upload session gate is wired, not open-mode", + "oracle": "api", + "verify": "POST /upload/presigned without a bearer returns 401 with code AUTH_REQUIRED; no sys_file row is created for the attempt", + "evidence": "the 401 body + a sys_file count before/after" + } + ], + "negative": [ + "a 2xx on the anonymous presign is a FAIL (it means the deployment is running in the bare-kernel open mode the resolver exists to close) — check the boot log for the one-time '[storage] no session resolver wired' notice before blaming the route", + "a downloadUrl that 404s when followed is the #3641 regression (a minted-but-unmounted URL) — FAIL, not environment" + ], + "traps": ["dispatcher-vs-hono-route"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "source": [ + "docs/plans/release-15.1-test-plan.md §A12 / §C1", + "packages/services/service-storage/src/storage-routes.ts (upload session gate #2755, envelope #3689, 302 door)", + "packages/services/service-storage/src/storage-route-ledger.ts (the audited route set at the default base; _local/raw is a server-only capability URL)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item grounded in the storage route ledger + storage-routes.ts source and the #2755 dogfood matrix (upload gate, owner stamping, envelope, 302 door)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "attachments-storage.download-authz-both-sides", + "title": "Gated downloads: 401 anonymous, 403 parent-invisible, signed URL for the entitled — per gating class (attachments-scope / field-owned / public_read opt-out / ungated)", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin (uploader/owner)", "signed-up member who cannot read the parent record", "anonymous"], + "fixtures": { + "app": "showcase", + "requires": [ + "a committed attachments-scope file attached to a parent record, and a field-owned file (e.g. a receipt uploaded into showcase_invoice_line.receipt — ref_object/ref_id stamped per ADR-0104 D3 wave 2)" + ], + "knownGaps": [ + "stock showcase's only files-enabled object (showcase_project) is public_read_write, so no stock parent is invisible to any member — the 403 deny side is proven by the pinned dogfood fixture (att_secret, private owner-scoped: fixtures/attachments-fixture.ts) or a scratch private parent, not on stock seeds", + "no stock seed mints an acl='public_read' attachments file (the anonymous-embed opt-out) — author one via a system write or accept the unit-test coverage in storage-routes.test.ts for that variant" + ] + }, + "steps": [ + "as admin, upload + attach a file to a parent record (attachments scope) and separately upload a receipt into an invoice line (field-owned; verify ref_object='showcase_invoice_line' and ref_id are stamped on its sys_file row)", + "GET /api/v1/storage/files//url with NO auth for each gated file and capture the refusals", + "GET the same routes as a member who cannot read the parent record (private parent per the fixture note) and capture the refusals — note the two DISTINCT deny codes", + "GET the same routes as the uploader/admin and follow the signed URL", + "compare TTLs: the gated grant uses the short downloadTtl (default 300s), not the 3600s presignedTtl — read expiry material from the minted URL/descriptor where the adapter exposes it", + "flip one file to acl='public_read' (system write) and GET /files/ anonymously — the opt-out must restore the stable anonymous capability URL", + "in the browser, open the parent record's RecordAttachmentsPanel as the denied member and capture the surfaced copy" + ], + "acceptance": [ + { + "clause": "anonymous download of a gated file (attachments-scope OR field-owned) is 401 AUTH_REQUIRED", + "oracle": "api", + "verify": "both gated classes answer 401 with code AUTH_REQUIRED when no session resolves", + "evidence": "the two 401 bodies" + }, + { + "clause": "an authenticated member without read access to the parent is 403, with the class-specific code: ATTACHMENT_DOWNLOAD_DENIED for attachments-scope, FILE_DOWNLOAD_DENIED for field-owned", + "oracle": "api", + "verify": "the 403 bodies carry exactly those codes (storage-routes.ts authorizeDownload); a failed authz check must deny, never fall open", + "evidence": "the two 403 bodies" + }, + { + "clause": "the entitled caller (parent-visible member, or the uploader/owner who may ALWAYS download) receives a working short-lived signed URL", + "oracle": "api", + "verify": "200 { success: true, data: { url } } and the url serves the bytes; uploader bypass verified by downloading as the file's owner_id user against a parent they cannot otherwise read", + "evidence": "the grant + downloaded bytes" + }, + { + "clause": "acl='public_read' opts a file back out to the stable anonymous capability URL — the explicit declaration for embedding, which cannot carry a bearer token", + "oracle": "api", + "verify": "after setting acl public_read, the anonymous GET /files/:fileId 302s to bytes instead of 401ing", + "evidence": "before/after anonymous responses" + }, + { + "clause": "the browser surface renders the denial as friendly copy, not a raw error dump (objectui#2532) — the RecordAttachmentsPanel maps the fail-closed 40x codes to readable text", + "oracle": "screenshot", + "verify": "the denied member's panel shows the mapped copy (RecordAttachmentsPanel.tsx friendlyError reads both the enveloped error.code and the legacy top-level code)", + "evidence": "panel screenshot" + } + ], + "negative": [ + "a gated file downloadable anonymously (silent success) is a FAIL — UI absence of a download button is a client courtesy; the route is the authority (ADR-0057 D10)", + "a deny that surfaces in the panel as 'Download failed (403)' instead of the mapped copy means the error-envelope dialect broke (#3689 note in the panel) — file it against objectui, not storage" + ], + "traps": ["wrong-persona", "dispatcher-vs-hono-route", "stale-console-bundle"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "source": [ + "docs/plans/release-15.1-test-plan.md §A12 / §C1 (#2755/#2970)", + "packages/services/service-storage/src/storage-routes.ts (authorizeDownload: gating classes, verdict→status mapping, downloadTtl vs presignedTtl)", + "packages/services/service-storage/src/storage-service-plugin.ts (buildFileReadAuthorizer: owner bypass, field-owned single-parent read, fail-closed delegate)", + "objectui packages/app-shell/src/views/RecordAttachmentsPanel.tsx (friendly denial copy, objectui#2532)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item enumerating the download gating classes straight from authorizeDownload's source (attachments-scope / field-owned / public_read opt-out) with the exact deny codes and TTL contrast", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "attachments-storage.read-inherits-parent-rls", + "title": "sys_attachment reads inherit parent visibility: a restricted member sees neither rows nor counts for invisible parents", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["member who can read the parent record", "member who cannot read the parent record"], + "fixtures": { + "app": "showcase", + "requires": [ + "attachments on a parent record that is INVISIBLE to one persona and visible to the other" + ], + "knownGaps": [ + "stock showcase's files-enabled parent (showcase_project) is public_read_write — every member sees every project, so the invisible-parent case is not demonstrable on stock seeds; the pinned dogfood matrix proves it on its own private fixture (att_secret). A stock-seed demonstration needs a private files-enabled object added to the showcase" + ] + }, + "steps": [ + "seed the split: attach files to a record the restricted member cannot read (private, owned by admin) and to one they can", + "as the restricted member, GET /api/v1/data/sys_attachment (list scoped by parent_object + parent_id of the invisible record) and record rows AND total", + "as the same member, GET the invisible parent's attachment by id directly", + "run the same reads as the entitled member", + "as the restricted member, run an unscoped sys_attachment list and record which rows appear", + "run a count/aggregate over sys_attachment as both personas and compare totals" + ], + "acceptance": [ + { + "clause": "the restricted member's list returns zero rows for the invisible parent — attachment metadata (file_name, size, parent_id) never leaks", + "oracle": "api", + "verify": "the scoped list is empty for the restricted member and non-empty for the entitled one", + "evidence": "both list responses" + }, + { + "clause": "the COUNT is filtered identically to the rows — the visibility filter is a data middleware precisely so list `total` (engine.count, not the find path) cannot leak the true row count", + "oracle": "api", + "verify": "total/count for the restricted member excludes invisible-parent rows; the entitled member's count includes them", + "evidence": "the paired counts" + }, + { + "clause": "a direct by-id read of an invisible parent's attachment resolves to nothing for the restricted member", + "oracle": "api", + "verify": "findOne/GET by id returns not-found/empty, not the row", + "evidence": "the by-id response" + }, + { + "clause": "the filter fails CLOSED: a filter-compute failure or a pre-scan past the 2000-candidate cap excludes rows rather than leaking them, and the cap logs a warning naming the fail-closed truncation", + "oracle": "log", + "verify": "on a very broad unscoped list, either all rows resolve visibly or the '[storage] attachment read visibility' warning appears — silence plus leaked rows is the failure", + "evidence": "log excerpt for the broad-read case" + } + ], + "negative": [ + "rows visible to the restricted member whose parent they cannot read is the #2970 info leak this item exists for — FAIL", + "rows filtered but `total` counting the raw table is equally a FAIL (the middleware-not-hook design note in attachment-access-hooks.ts exists because a find-hook leaves count() unfiltered)" + ], + "traps": ["wrong-persona"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "source": [ + "docs/plans/release-15.1-test-plan.md §C3 (#2970)", + "packages/services/service-storage/src/attachment-access-hooks.ts (installAttachmentReadVisibility: middleware over find/findOne/count/aggregate, deny-all sentinel, READ_SCAN_LIMIT fail-closed cap)", + "packages/services/service-storage/src/attachment-read-visibility.test.ts" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item grounded in the read-visibility middleware source (count-parity rationale, fail-closed sentinel and scan cap) and the dogfood matrix clause (c)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "attachments-storage.attach-requires-parent-edit", + "title": "Attaching requires EDIT on the parent record; deleting requires uploader-or-parent-editor; unscoped multi-delete is refused outright", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["member with EDIT on the parent", "member with READ but not EDIT on the parent", "member who uploaded the attachment", "member who is neither uploader nor parent editor"], + "fixtures": { + "app": "showcase", + "requires": [ + "a files-enabled parent readable-but-not-editable by one persona (the dogfood matrix builds this with its own fixture; on stock showcase, projects are public_read_write so any member can edit — the read-not-edit split needs the fixture or a sharing tweak)", + "plugin-sharing present (canEdit is the authority; without it the gate degrades to parent READ visibility by design)" + ] + }, + "steps": [ + "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 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", + "issue a DELETE against /api/v1/data/sys_attachment with NO id and NO where predicate and capture the refusal" + ], + "acceptance": [ + { + "clause": "attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS — Salesforce parity (#2970 item 3): canEdit on the parent, not mere read", + "oracle": "api", + "verify": "the read-only member's insert is refused with that code, while the same member's LIST of the parent's attachments succeeds", + "evidence": "the 403 + the successful list" + }, + { + "clause": "uploaded_by is server-stamped from the session — a spoofed value never wins", + "oracle": "api", + "verify": "the created row's uploaded_by equals the caller's user id, not the spoofed one", + "evidence": "the row read" + }, + { + "clause": "delete is gated on uploader-or-parent-editor: the outsider gets 403 ATTACHMENT_DELETE_DENIED; the uploader succeeds even without parent edit", + "oracle": "api", + "verify": "the two delete attempts split exactly that way; a multi-delete requires EVERY matched row to pass", + "evidence": "both delete responses" + }, + { + "clause": "an unscoped multi-delete (no id AND no where) is refused outright (#4757) — 'nothing was ever queried' must not read as 'nothing to authorize'", + "oracle": "api", + "verify": "the predicate-less delete returns 403 ATTACHMENT_DELETE_DENIED with the refusing-unscoped message; the table row count is unchanged", + "evidence": "the refusal + before/after counts" + }, + { + "clause": "attaching to an object without enable.files is 403 FILES_DISABLED (the #2727 opt-in gate, enforced by plugin-audit alongside these hooks)", + "oracle": "api", + "verify": "an attach targeting e.g. showcase_account (no enable.files) is refused with FILES_DISABLED", + "evidence": "the refusal" + } + ], + "negative": [ + "a successful attach by the read-only member is a FAIL even if the UI hides the upload control — the hook, not the panel, is the boundary", + "a rejected write that still created the sys_attachment row (verify by re-listing) is a FAIL — the rejection must be authoritative, not cosmetic" + ], + "traps": ["wrong-persona"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "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 uploader-or-editor + #4757 unscoped refusal, MULTI_DELETE_AUTH_LIMIT fail-closed)", + "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (items 3, a, f; FILES_DISABLED)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item from the access-hook source: EDIT-not-read attach gate, server stamping, delete authorization matrix and the #4757 unscoped-delete refusal, each with its exact deny code", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "attachments-storage.sys-file-status-pipeline", + "title": "sys_file status pipeline: pending → committed → deleted (tombstone) with un-tombstone on re-attach; shared files never tombstone early", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_project (enable.files) with seeded projects 'Website Relaunch' and 'Data Platform' as the two attach targets" + ] + }, + "steps": [ + "presign an attachments-scope upload and read the sys_file row: status 'pending'", + "attempt GET /api/v1/storage/files//url while still pending and capture the refusal (downloads only serve committed files)", + "complete the upload; re-read: status 'committed'", + "attach the file to 'Website Relaunch' AND 'Data Platform' (two sys_attachment join rows over ONE file — the Salesforce ContentDocumentLink share pattern)", + "delete the 'Data Platform' join row and re-read sys_file: still 'committed' (a remaining reference blocks the tombstone)", + "delete the LAST join row and re-read: status 'deleted' with deleted_at set (the tombstone)", + "re-attach the same file_id to 'Website Relaunch' within the grace window and re-read: status back to 'committed', deleted_at null", + "verify a NON-attachments-scope file (e.g. an invoice-line receipt, scope from the field-upload path) is never tombstoned by these join-row hooks" + ], + "acceptance": [ + { + "clause": "each variant of the status enum (pending / committed / deleted — the full option set declared on sys_file.status) is reached through its real transition, verified by API reads, never inferred", + "oracle": "api", + "verify": "the sequence of sys_file reads shows pending→committed on complete, committed→deleted on last-reference delete, deleted→committed on re-attach", + "evidence": "the read sequence, one per transition" + }, + { + "clause": "a pending (never-completed) file is not downloadable: the download routes answer 404 FILE_NOT_FOUND for status != committed", + "oracle": "api", + "verify": "the /url GET during the pending window returns 404 with that code", + "evidence": "the 404 body" + }, + { + "clause": "one file shared by two join rows survives losing one of them — deleting an attachment deletes only the join row; the tombstone fires only when the LAST reference goes", + "oracle": "api", + "verify": "sys_file still committed after the first join-row delete; deleted only after the second", + "evidence": "the two post-delete reads" + }, + { + "clause": "re-attaching before the 30d grace window un-tombstones (status committed, deleted_at cleared) — the tombstone is recoverable state, not a delete", + "oracle": "api", + "verify": "post-re-attach read shows the revived row", + "evidence": "the read" + }, + { + "clause": "only scope='attachments' committed files tombstone via these hooks — field-owned scopes have their own release seam (file-reference-lifecycle.ts) and must not be touched by join-row counting", + "oracle": "api", + "verify": "a non-attachments-scope file's status is unchanged by sys_attachment deletes", + "evidence": "before/after reads of the field-file row" + } + ], + "negative": [ + "a tombstone firing while a reference remains (the shared-file case) is a FAIL — it is the exact naive cascade the join-row model exists to prevent", + "lifecycle bookkeeping blocking or failing the user's delete is a FAIL: the hooks are declared best-effort (fail toward retention, log-only)" + ], + "variants": ["pending", "committed", "deleted"], + "traps": ["wrong-panel"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "source": [ + "packages/services/service-storage/src/objects/system-file.object.ts (status enum + lifecycle declaration; the variants list is that enum)", + "packages/services/service-storage/src/attachment-lifecycle.ts (tombstone/un-tombstone hooks, last-reference rule, attachments-scope discriminator)", + "#3358 §7 (sys_file detail page + status pipeline)", + "docs/plans/release-15.1-test-plan.md §C4 (#2755)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: the full status pipeline as a variants matrix over the sys_file.status enum, with the shared-file and re-attach transitions from the lifecycle-hook source", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "attachments-storage.orphan-tombstone-reap", + "title": "The platform lifecycle sweep reaps expired tombstones and abandoned pending uploads WITH byte reclaim; sweep-time re-verification vetoes rather than losing data", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["admin (setup access, drives the sweep)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the ability to backdate deleted_at/created timestamps past the TTLs (system writes) so the sweep selects candidates without waiting 30d/7d — the pinned dogfood suite does exactly this" + ] + }, + "steps": [ + "run the pinned dogfood lifecycle suite first (RUNNER rule 6 — do not hand re-prove what it pins) and capture its output", + "for the hand-driven spot check: tombstone a file (last join-row delete), backdate deleted_at past 30d, trigger the LifecycleService sweep, then verify the sys_file row AND its storage bytes are gone", + "create a pending upload, backdate past the 7d retention, sweep, verify row + best-effort bytes gone", + "create a tombstone, then re-add a sys_attachment reference BEHIND the hooks (system write), backdate, sweep — verify the row is un-tombstoned (committed, deleted_at null), not reaped", + "verify fresh tombstones (inside the window), committed rows and NULL-deleted_at rows all survive the sweep untouched" + ], + "acceptance": [ + { + "clause": "each reap-guard verdict variant behaves per its declared contract: pending → bytes best-effort deleted + row confirmed; deleted with zero references and no field owner → bytes reclaimed BEFORE the row delete; deleted-but-regained-reference → un-tombstoned and vetoed; non-attachments-scope tombstone with the ADR-0104 migration gate closed → kept (vetoed, still tombstoned); byte-delete failure → vetoed and retried next sweep", + "oracle": "test", + "verify": "the dogfood lifecycle describe block ('sys_file orphan lifecycle (ADR-0057 reap guard)') passes; its cases map one-to-one onto these variants", + "evidence": "test run output" + }, + { + "clause": "bytes are reclaimed before the row delete — the row is the only pointer to the bytes, so dropping it first would leak them forever", + "oracle": "api", + "verify": "after the sweep, the storage backend no longer holds the reaped key AND the sys_file row is gone; a failed byte delete leaves the row for retry (check the warn log)", + "evidence": "backend listing + row read + log excerpt" + }, + { + "clause": "sweep-time re-verification is real: a reference regained behind the hooks' back (hook bypass, restore) un-tombstones instead of reaping", + "oracle": "api", + "verify": "the re-referenced file survives the sweep as committed", + "evidence": "post-sweep read" + }, + { + "clause": "committed rows are immortal: nothing with status='committed' or NULL deleted_at is ever a candidate", + "oracle": "api", + "verify": "control rows are unchanged after the sweep", + "evidence": "control-row reads" + } + ], + "negative": [ + "a reap that deletes the row but leaves the bytes (or vice versa without a veto) is a FAIL — the guard's ordering contract exists precisely for this", + "the sweep reaping a fresh (in-window) tombstone is a FAIL against the TTL declaration on system-file.object.ts" + ], + "variants": [ + "pending (7d retention, bytes best-effort)", + "deleted + zero refs (30d TTL, bytes reclaimed then row)", + "deleted + regained reference (un-tombstone, veto)", + "deleted + field-file lineage, migration gate closed (kept, veto without un-tombstone)", + "byte-delete failure (veto, retry next sweep)" + ], + "traps": ["stale-dist"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "source": [ + "packages/services/service-storage/src/attachment-lifecycle.ts (createSysFileReapGuard — the verdict variants are its documented branch set, incl. the ADR-0104 isCollectionOpen gate re-read each sweep)", + "packages/services/service-storage/src/objects/system-file.object.ts (lifecycle: ttl deleted_at+30d, retention 7d onlyWhen pending)", + "docs/plans/release-15.1-test-plan.md §C4 (#2755/#2970)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: reap-guard verdict matrix enumerated from createSysFileReapGuard's source, pinned to the dogfood lifecycle suite; TTL/retention figures from the sys_file lifecycle declaration", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "attachments-storage.upload-session-abort", + "title": "Chunked uploads are resumable; abandoned sessions are reaped and their backend multipart uploads ABORTED before the row (the only pointer) is deleted", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the four chunked routes at the default base: POST /upload/chunked, PUT /upload/chunked/:uploadId/chunk/:chunkIndex, POST /upload/chunked/:uploadId/complete, GET /upload/chunked/:uploadId/progress" + ], + "knownGaps": [ + "the billable-stranded-parts consequence (S3 keeps initiated-but-uncompleted multipart parts invisible until AbortMultipartUpload) is only observable against a real S3 backend — stock local-adapter runs prove the session-row lifecycle and that the guard invokes abortChunkedUpload; the S3 key re-seeding path (setUploadKey on a cold sweep) is pinned by unit tests in attachment-lifecycle.test.ts, not demonstrable on local" + ] + }, + "steps": [ + "POST /api/v1/storage/upload/chunked { filename, mimeType, size, chunkSize } and capture { uploadId, fileId }", + "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", + "verify the abandoned session's row is reaped AND the storage adapter's abortChunkedUpload was invoked for its backend_upload_id (dogfood suite instruments this)", + "verify the completed session's row is reaped by the 7d terminal-status retention WITHOUT an abort attempt (an abort on a finalized multipart would NoSuchUpload-error and wedge the reap)", + "simulate an abort failure (test seam) and verify the row is VETOED — kept so backend_upload_id survives for the retry" + ], + "acceptance": [ + { + "clause": "the chunked round-trip works over the real routes: init → chunk PUTs → progress read → complete, with the session row tracking uploadedChunks and status through the declared enum", + "oracle": "api", + "verify": "progress responses and sys_upload_session reads at each stage match the declared shape", + "evidence": "the progress/read sequence" + }, + { + "clause": "an abandoned in_progress session with a backend_upload_id is reaped only AFTER a successful backend multipart abort — parts never stranded with their only pointer gone", + "oracle": "test", + "verify": "the dogfood case '(item 4 + multipart-abort guard) an abandoned chunked upload is reaped AND its uploaded parts are aborted' passes", + "evidence": "test run output" + }, + { + "clause": "completed sessions (and sessions with no backend_upload_id, and adapters without abortChunkedUpload) reap WITHOUT an abort call", + "oracle": "test", + "verify": "the guard confirms those rows directly per its documented branch set", + "evidence": "test output / instrumented call log" + }, + { + "clause": "an abort failure vetoes: the row survives the sweep and is retried, with the '[storage] reap guard: multipart abort failed' warning logged", + "oracle": "log", + "verify": "the veto case keeps the row and logs the retry warning", + "evidence": "log excerpt + row read" + }, + { + "clause": "every sys_upload_session status enum variant (in_progress / completing / completed / failed / expired) is reachable and terminal ones fall under the 7d retention backstop", + "oracle": "api", + "verify": "status reads across the scenarios cover the enum; retention onlyWhen matches {status: {$in: [completed, failed, expired]}}", + "evidence": "per-variant status reads" + } + ], + "negative": [ + "a sweep that deletes the session row while the backend abort failed (silent success) is a FAIL — the row's backend_upload_id is the sole pointer to the leaked multipart", + "an abort attempted against a COMPLETED session is a FAIL (it would NoSuchUpload-error and wedge the reap)" + ], + "variants": ["in_progress", "completing", "completed", "failed", "expired"], + "traps": ["stale-dist"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "source": [ + "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)", + "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" } + ] + }, + { + "id": "attachments-storage.inline-grid-receipt-cells", + "title": "Per-line Receipt upload cells in the invoice inline grid: auto-derived file column, real upload control, resolved file object in the atomic batch", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_invoice_line.receipt (Field.file) and the seeded product 'Widget A' (sku WIDGET-A) for the line's product pick", + "the storage service live (the upload rides the console's UploadProvider adapter into /api/v1/storage)" + ] + }, + "steps": [ + "open /apps/showcase_app/showcase_invoice, click New, and wait for the Line Items grid's real headers (screenshot first)", + "verify the Receipt column auto-derived into the grid (no columns config authors it — file fields must not be dropped from auto-columns)", + "verify the Receipt cell is a genuine input[type=file] upload control and NOT a text input", + "materialize a row: pick 'Widget A' in the product lookup, set Qty 1", + "upload a small PNG into the row's Receipt cell and wait for the file chip carrying the file name", + "fill the header (name INV-QA-, pick an account, status draft) and submit while capturing the POST /api/v1/batch request", + "inspect the batch's showcase_invoice_line operation: the receipt value must be a RESOLVED stored-file object, and its url an absolute http(s) URL", + "re-open the created invoice and confirm the line's receipt renders as a chip/thumbnail, not a text value" + ], + "acceptance": [ + { + "clause": "the Receipt column auto-derives from the data model into the inline grid", + "oracle": "dom", + "verify": "after the screenshot confirms render, the grid header row contains exactly one 'Receipt' th", + "evidence": "screenshot + header DOM excerpt" + }, + { + "clause": "the cell is a real upload control — input[type=file] present, text input for Receipt absent (the objectui#2360 degraded-cell regression)", + "oracle": "dom", + "verify": "input[type=file] count >= 1 inside the grid; input[type=text][aria-label=Receipt] count == 0", + "evidence": "DOM assertion output" + }, + { + "clause": "picking a file uploads through the storage service and shows a removable chip with the file name before submit", + "oracle": "network", + "verify": "the upload requests hit /api/v1/storage/* and the chip appears with the picked name", + "evidence": "upload trace + chip screenshot" + }, + { + "clause": "the atomic /api/v1/batch carries the line with receipt as a resolved stored-file object (absolute url), not a blob or text placeholder — and the create round-trips", + "oracle": "network", + "verify": "the captured batch's showcase_invoice_line operation has receipt.name (or original_name) containing the picked filename and an http(s) url; the batch answers success", + "evidence": "the batch payload + response" + } + ], + "negative": [ + "a text input where the upload cell should be is the exact #2360 failure — FAIL", + "a batch that persists a data:/blob: placeholder instead of a stored-file reference is a FAIL even if the grid looked right (ADR-0104: an inline blob is not a managed file)" + ], + "traps": ["automation-input", "hydration-race", "stale-console-bundle"], + "automated": { "kind": "e2e", "ref": "objectui: e2e/live/grid-file-upload.spec.ts" }, + "source": [ + "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)", + "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" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item transcribed from the live e2e pin (grid-file-upload.spec.ts) with seeded names verified (Widget A / showcase_invoice_line.receipt); ADR-0059 form-side guard cross-referenced to records-forms instead of duplicated", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json new file mode 100644 index 0000000000..35308fad62 --- /dev/null +++ b/docs/qa/platform-checklist/areas/automation.json @@ -0,0 +1,1034 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "automation", + "title": "Automation — flows, triggers, roll-ups", + "items": [ + { + "id": "automation.flow-run-step-nesting", + "title": "Flow Runs render loop/region iterations as a nested execution tree", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_batch_reminders (examples/app-showcase/src/automation/flows/index.ts BatchRemindersFlow) — an autolaunched loop flow with a `tasks` list input, runnable on demand via the trigger route" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "POST /api/v1/automation/showcase_batch_reminders/trigger with body {\"params\": {\"tasks\": [t1, t2, t3]}} — three task-shaped objects each carrying id/title/owner so the loop body's notify node can interpolate {task.title}/{task.owner}", + "GET /api/v1/automation/showcase_batch_reminders/runs, take the newest run id, then GET /api/v1/automation/showcase_batch_reminders/runs/:runId for the full step log", + "record every step's nodeId, nodeType, status, parentNodeId, iteration, regionKind (ExecutionStepLogSchema #1505 region tags)", + "open the flow in the Studio flow-designer (metadata-admin) and its Runs panel (FlowRunsPanel) — NOT the developer Flow Runs page — and expand the newest run", + "screenshot the expanded step tree showing the per-iteration children under the loop node", + "contrast run: trigger again with {\"params\": {\"tasks\": []}} and capture the loop step of that run" + ], + "acceptance": [ + { + "clause": "the 3-item run completes (status=completed) and the loop body's send_reminder node executed exactly 3 times", + "oracle": "api", + "verify": "GET the run detail: status=completed; count steps with nodeId=send_reminder == 3", + "evidence": "run-detail API read" + }, + { + "clause": "every body step carries the region tags: parentNodeId=loop_tasks, iteration in 0..2, regionKind='loop-body'", + "oracle": "api", + "verify": "assert the three send_reminder steps each carry {parentNodeId:'loop_tasks', iteration: 0|1|2, regionKind:'loop-body'} and no two share an iteration; top-level steps (start/loop_tasks/end) carry NO parentNodeId", + "evidence": "step-log excerpt with the tags" + }, + { + "clause": "the designer Runs panel renders the iterations as a nested tree (per-iteration children folded under the loop node, labeled 1-based), not a flat list", + "oracle": "screenshot", + "verify": "screenshot of the expanded run shows send_reminder rows indented under 'For each task' grouped by iteration — matches FlowRunsPanel buildStepTree (#1505)", + "evidence": "Runs panel screenshot" + }, + { + "clause": "the rendered tree agrees with the API: same step count and same parent/iteration grouping", + "oracle": "api", + "verify": "cross-check the screenshot's grouping against the recorded parentNodeId/iteration tags — a tree the API tags cannot reconstruct is a rendering invention", + "evidence": "screenshot + step-log side by side" + }, + { + "clause": "empty-collection contrast: the 0-item run still completes and its loop step succeeds with zero body children", + "oracle": "api", + "verify": "run 2 detail: status=completed, no steps with parentNodeId=loop_tasks — 'nothing to iterate' is a success with an empty region, not a failure", + "evidence": "second run-detail read" + } + ], + "negative": [ + "a flat rendering in which all steps show success is a FAIL of the nesting clause — every step passing is exactly what makes the missing tree easy to tick past; the developer Flow Runs page renders steps flat and must never be cited as this item's oracle" + ], + "traps": [ + "wrong-panel", + "hydration-race" + ], + "source": [ + "#3358 §2 — 'the developer Flow Runs page renders steps flat; looking there alone reads as a miss'", + "packages/spec/src/automation/execution.zod.ts (ExecutionStepLogSchema parentNodeId/iteration/regionKind)", + "objectui packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx (#1505 buildStepTree)", + "examples/app-showcase/src/automation/flows/index.ts (BatchRemindersFlow)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358, encoding its wrong-panel lesson as a trap", + "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" + } + ] + }, + { + "id": "automation.time-relative-trigger", + "title": "Time-relative flow triggers author first-class and fire per matching record", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_task_due_reminder (type schedule, start config.timeRelative {object: showcase_task, dateField: due_date, offsetDays: [3,1], filter: {status: {$ne: 'done'}}}, runAs system)" + ], + "knownGaps": [ + "the stock sweep cadence is the timeRelative default — daily 08:00 UTC — so an in-session fire needs either a cadence override (config.schedule cron/interval on a writable copy of the flow) or a manually provoked sweep; the run record must state which method was used" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "open showcase_task_due_reminder in the Studio flow-designer; open the start node's panel", + "screenshot the first-class timeRelative editor (sweep object / date field / offset days as a numberList editor — not raw JSON)", + "read the server boot log: the Flows banner must count showcase_task_due_reminder bound as a time_relative trigger (requires: ['triggers'] — engine warns 'no time_relative trigger is registered' otherwise)", + "POST /api/v1/data/showcase_task: one task with due_date exactly 3 days from today and status='todo' (matches offsetDays AND the filter), one with due_date 10 days out (no offset match), one due in 3 days but status='done' (excluded by the filter)", + "fire the sweep (per the knownGaps method) and record how it was fired", + "GET /api/v1/automation/showcase_task_due_reminder/runs — locate the run(s) produced by the sweep", + "GET the matching run's detail: trigger kind, the interpolated notify step, and the recipient", + "author-negative: in a scratch/writable package author a flow whose timeRelative sets BOTH offsetDays and withinDays, and build/validate it" + ], + "acceptance": [ + { + "clause": "the designer renders the dedicated timeRelative panel (sweep object / date field / offset days), not a raw-JSON fallback", + "oracle": "screenshot", + "verify": "designer panel screenshot shows the named fields populated from the flow source", + "evidence": "screenshot" + }, + { + "clause": "the flow binds at boot as a time_relative trigger", + "oracle": "log", + "verify": "boot log counts it bound as time_relative (trigger-schedule time-relative-trigger.ts registers type 'time_relative'); no 'no time_relative trigger is registered' warning", + "evidence": "boot log excerpt" + }, + { + "clause": "the sweep launches the flow once for the matching record, with that record on the flow context, and the run row records the time_relative trigger kind", + "oracle": "api", + "verify": "exactly one run whose trigger records time_relative (engine stamps triggerType 'time_relative' — service-automation engine.ts) and whose notify step interpolated the staged task's {record.title}", + "evidence": "runs list + run-detail reads" + }, + { + "clause": "non-matching records produce NO run: the 10-days-out task (outside offsetDays) and the done task (excluded by filter) are both skipped", + "oracle": "api", + "verify": "runs list contains no run whose context record is either non-matching task — assert the absence, not just the presence", + "evidence": "runs list read after the sweep" + }, + { + "clause": "declaring both windowing modes is rejected at authoring: timeRelative requires exactly ONE of withinDays / offsetDays", + "oracle": "build", + "verify": "the both-modes scratch flow fails build/validate with a located error naming the constraint (packages/spec/src/automation/time-relative-trigger.zod.ts)", + "evidence": "build/validate output" + } + ], + "negative": [ + "a sweep that also fires for the filter-excluded or out-of-window record is a FAIL even though the matching record fired correctly", + "a both-windowing-modes flow accepted silently is a FAIL — the schema declares exactly one mode" + ], + "traps": [ + "seed-data-thin", + "stale-dist" + ], + "source": [ + "#3358 §2", + "packages/spec/src/automation/time-relative-trigger.zod.ts", + "examples/app-showcase/src/automation/flows/index.ts (TaskDueReminderFlow, #1874)", + "packages/triggers/trigger-schedule/src/time-relative-trigger.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "automation.rollup-summary-filter", + "title": "Filtered roll-up summaries recompute only through their own filter", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a parent with several filtered roll-ups in different filter shapes (showcase_expense_report: equality / boolean / operator / unfiltered)" + ], + "knownGaps": [ + "the visual filter-editor half needs a summary field in a WRITABLE package — the showcase ships read-only, so Studio editing is not reachable on stock fixtures (#3358)" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "GET /api/v1/data/showcase_expense_report for EXP-2001 and record all six roll-ups: total_amount (unfiltered SUM), approved_amount (SUM where status=approved), reimbursable_amount (SUM where billable=true), line_count (unfiltered COUNT), rejected_count (COUNT where status=rejected), over_limit_count (COUNT where amount >= 500)", + "GET the child showcase_expense_line rows for that report and independently recompute each roll-up from them", + "PATCH one line's status approved→rejected over /api/v1/data/showcase_expense_line/:id; re-read the parent and recompute again", + "PATCH a different line's billable true→false; re-read the parent", + "DELETE one line; re-read the parent — a delete must move every roll-up whose filter matched the deleted row, including the unfiltered total", + "(editor half, blocked) on a writable package: edit a summary field's child-row filter in Studio's visual filter editor, save, and re-read the persisted metadata" + ], + "acceptance": [ + { + "clause": "baseline: every roll-up equals an independent recomputation from the child rows before any edit", + "oracle": "api", + "verify": "value table: six roll-ups vs six hand-recomputed aggregates over the fetched lines — all equal", + "evidence": "parent + child reads with the recomputation table" + }, + { + "clause": "after the status flip, only the status-filtered roll-ups move (approved_amount down, rejected_count up) and each lands exactly on the recomputed value", + "oracle": "api", + "verify": "before/after parent reads: approved_amount and rejected_count equal recomputation; total_amount, line_count, reimbursable_amount, over_limit_count byte-identical to before (the part a naive recompute-everything gets wrong)", + "evidence": "before/after value table" + }, + { + "clause": "after the billable flip, only reimbursable_amount moves", + "oracle": "api", + "verify": "before/after reads: reimbursable_amount equals recomputation; all five other roll-ups byte-identical", + "evidence": "before/after value table" + }, + { + "clause": "a child delete recomputes every roll-up the deleted row participated in, including the unfiltered total and count", + "oracle": "api", + "verify": "after DELETE: total_amount and line_count drop by the deleted row's contribution; filtered roll-ups whose predicate matched it drop too; roll-ups whose predicate did not match are unchanged", + "evidence": "before/after value table" + }, + { + "clause": "the child-row filter is settable via the visual editor on a writable package", + "oracle": "screenshot", + "verify": "edit a summary field's filter in Studio on a writable package and confirm the persisted metadata via the meta API", + "evidence": "editor screenshot + saved metadata read" + } + ], + "negative": [ + "an untouched-filter roll-up that moves on an edit outside its filter (e.g. rejected_count changing on the billable flip) is a FAIL even if every touched value is right" + ], + "blocked": { + "by": "fixture", + "ref": "#3358 (editor half needs a writable-package summary field fixture)" + }, + "traps": [ + "seed-data-thin" + ], + "source": [ + "#3358 §2 (recompute half proven; editor half explicitly left unticked)", + "examples/app-showcase/src/data/objects/expense-report.object.ts (the six roll-up shapes and their filters)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358; clause 1 runnable today, clause 2 carries the fixture blocker", + "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" + } + ] + }, + { + "id": "automation.flow-node-type-matrix", + "title": "Every demonstrable flow node type authors in the designer, executes in a run, and surfaces its step in run logs", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase flow-per-node-type map (examples/app-showcase/src/automation/flows/): create_record=showcase_inbound_task_webhook · update_record+screen=showcase_reassign_wizard · get_record+decision+delete_record=showcase_inquiry_purge · script+notify=showcase_task_completed · wait=showcase_task_follow_up · subflow=showcase_task_done_notify_owner→showcase_notify_owner · map=showcase_release_signoff · connector_action=showcase_task_completed_rest_ping · loop=showcase_batch_reminders · parallel+http=showcase_fan_out_notify · try_catch=showcase_resilient_sync · assignment=showcase_closure_signoff · approval=showcase_expense_signoff · approval_revise=showcase_budget_approval (wait_revision)" + ], + "knownGaps": [ + "parallel_gateway / join_gateway / boundary_event are deliberately NOT in the matrix: BPMN-interop lowering targets whose author-facing forms are the ADR-0031 structured containers — waived with reasons in examples/app-showcase/src/coverage.ts FLOW_NODE_WAIVERS, and the coverage test enforces every OTHER enum member is authored" + ] + }, + "variants": [ + "start", + "end", + "decision", + "assignment", + "loop", + "create_record", + "update_record", + "delete_record", + "get_record", + "http", + "notify", + "script", + "screen", + "wait", + "subflow", + "map", + "connector_action", + "parallel", + "try_catch", + "approval", + "approval_revise" + ], + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "GET /api/v1/automation/actions (the designer palette feed) and record the descriptor list; open the Studio flow-designer palette and screenshot it", + "record_change chain: POST a showcase_task then PATCH status→done — fires showcase_task_completed (script+notify), showcase_fan_out_notify (parallel+http), showcase_resilient_sync (try_catch), showcase_task_done_notify_owner (subflow); the create also fires showcase_task_follow_up (wait) and showcase_declarative_connector_ping / showcase_mcp_connector_echo (connector_action)", + "on-demand chain: POST /api/v1/automation//trigger for showcase_batch_reminders {params:{tasks:[…]}} (loop), showcase_inquiry_purge (get_record+decision+delete_record — seed at least one closed showcase_inquiry first), showcase_release_signoff {params:{items:[…]}} (map)", + "browser chain: run the Tasks row action showcase_bulk_reassign → showcase_reassign_wizard (screen + update_record)", + "approval chain: PATCH a showcase_expense_report to submitted (approval via showcase_expense_signoff); PATCH a showcase_project budget above 100000 to open showcase_budget_approval, then POST /api/v1/approvals/requests/:id/revise so the run parks at the approval_revise node", + "webhook chain: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with a valid x-objectstack-signature HMAC (secret 'showcase-webhook-secret') to exercise create_record", + "for EACH variant: GET the owning flow's runs (GET /api/v1/automation//runs + /runs/:runId) and locate a step whose nodeType equals the variant; record run id, step status, and — for suspending types — the paused-then-resumed transition", + "screenshot the Runs panel of one composite run (showcase_project_escalation or showcase_fan_out_notify) showing the container-nested steps" + ], + "acceptance": [ + { + "clause": "the designer palette (fed by GET /api/v1/automation/actions) offers every variant in the matrix as an authorable node", + "oracle": "api", + "verify": "the actions descriptor list contains every variant id; the palette screenshot (taken after render) shows them offered", + "evidence": "actions API read + palette screenshot" + }, + { + "clause": "per-variant: each node type appears as an executed step (status success, or the documented pause for suspending types) in at least one captured run — every variant individually verified and recorded", + "oracle": "api", + "verify": "for each of the 21 variants cite the run id + step whose nodeType matches; a variant with no located step is UNPROVEN for that variant, not a partial pass of the matrix", + "evidence": "per-variant table of run id / step / status" + }, + { + "clause": "suspending types (screen, wait, approval, approval_revise — and subflow/map when the child pauses) park the run status=paused and resume to completed", + "oracle": "api", + "verify": "run detail shows status paused while parked and completed after the screen resume / timer elapse / approval decision / resubmit (ExecutionStatus vocabulary: pending|running|paused|completed|failed|cancelled|timed_out|retrying)", + "evidence": "before/after run-detail reads per suspending type" + }, + { + "clause": "every step in every captured run log names its nodeType, so the matrix is auditable from the run API alone", + "oracle": "api", + "verify": "no step in the captured runs has an empty/missing nodeType", + "evidence": "step-log excerpts" + }, + { + "clause": "container-region body steps (loop / parallel / try_catch) carry their region tags in the same runs", + "oracle": "api", + "verify": "spot-check one body step per container kind for parentNodeId + regionKind (deep coverage lives in automation.flow-run-step-nesting)", + "evidence": "tagged step excerpts" + } + ], + "negative": [ + "registering a flow with an unregistered node type (e.g. type 'bogus_node') must be REFUSED at registerFlow / POST /api/v1/automation — the type is validated against the live action registry (ADR-0018), and silent acceptance of an inert node is the #1887 failure shape" + ], + "traps": [ + "wrong-panel", + "seed-data-thin", + "automation-input" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/flow-node.dogfood.test.ts" + }, + "source": [ + "packages/spec/src/automation/flow.zod.ts (FlowNodeAction — the built-in seed set; type validated at registerFlow, not by a closed enum)", + "packages/spec/src/automation/control-flow.zod.ts (LOOP_NODE_TYPE / PARALLEL_NODE_TYPE / TRY_CATCH_NODE_TYPE, ADR-0031)", + "packages/spec/src/automation/approval.zod.ts (APPROVAL_NODE_TYPE, APPROVAL_REVISE_NODE_TYPE)", + "examples/app-showcase/src/coverage.ts (flowNodeTypes + FLOW_NODE_WAIVERS)", + "examples/app-showcase/src/automation/flows/index.ts", + "packages/runtime/src/route-ledger.ts (GET /automation/actions, POST /automation/:name/trigger, GET /automation/:name/runs)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — node-type matrix derived from FlowNodeAction + ADR-0031 containers + plugin-approvals registry types, mapped onto the seeded showcase flows", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly", + "ref": "claude/platform-test-checklist-ocwugl" + } + ], + "enumSource": { + "file": "packages/spec/src/automation/flow.zod.ts", + "export": "FlowNodeAction", + "expect": 20 + } + }, + { + "id": "automation.trigger-type-matrix", + "title": "Every flow trigger type fires and its run row records the trigger kind", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "one seeded flow per trigger kind: record_change=showcase_urgent_task_alert (record-after-write, the only type:'record_change' flow) · autolaunched=showcase_batch_reminders (trigger route) · schedule=showcase_scheduled_digest (60s interval) · screen=showcase_reassign_wizard (Tasks row action showcase_bulk_reassign) · api=showcase_inbound_task_webhook (HMAC intake hook) · time_relative=showcase_task_due_reminder (schedule flow hosting config.timeRelative)" + ] + }, + "variants": [ + "record_change", + "autolaunched", + "schedule", + "screen", + "api", + "time_relative" + ], + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "record_change (create leg): POST /api/v1/data/showcase_task with priority='urgent' — showcase_urgent_task_alert fires via record-after-write with previous == null (#3427)", + "record_change (update leg): POST a normal-priority task, then PATCH priority→'urgent'; also PATCH a task status→'done' and confirm a further save WITHOUT a transition fires nothing", + "api: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with JSON {title, assignee, project} and header x-objectstack-signature: sha256= — expect a 202 ACK; then find the created showcase_task", + "schedule: wait up to ~60s for showcase_scheduled_digest (interval 60000ms) to tick; wait a second interval for a second tick", + "screen: from /apps/showcase_app/showcase_task run the row action showcase_bulk_reassign, fill the dialog, submit", + "autolaunched: POST /api/v1/automation/showcase_batch_reminders/trigger {params:{tasks:[…]}}", + "time_relative: execute per automation.time-relative-trigger; capture only its run row here for the trigger-kind tally", + "for each variant, GET /api/v1/automation//runs and record the newest run's trigger block (ExecutionLogSchema.trigger)" + ], + "acceptance": [ + { + "clause": "per-variant: each trigger kind produces a run whose run row records that trigger kind — all six individually verified", + "oracle": "api", + "verify": "for each variant cite the run id and its trigger.type (record_change runs also carry the object + recordId of the mutation); a variant with no run row is UNPROVEN, whatever the UI showed", + "evidence": "per-variant table of run id + trigger block" + }, + { + "clause": "record-after-write discriminates its legs: the created-urgent task and the escalated task each produce exactly one run; the no-transition save produces none", + "oracle": "api", + "verify": "runs list for showcase_urgent_task_alert: two runs (one per leg), and NO run whose trigger recordId is the no-transition save — assert the absence", + "evidence": "runs list + the three data mutations" + }, + { + "clause": "the webhook intake ACKs 202 and the flow reads the JSON payload as its record", + "oracle": "api", + "verify": "intake response is 202; the created showcase_task's title/assignee/project equal the POSTed body ({record.*} interpolation from the webhook payload)", + "evidence": "intake request/response + task read" + }, + { + "clause": "the schedule trigger fires repeatedly on its interval without manual help", + "oracle": "api", + "verify": "two ticks ⇒ two showcase_scheduled_digest run rows (and two inbox rows for admin@objectos.ai), timestamps ~60s apart", + "evidence": "runs list with timestamps" + }, + { + "clause": "the screen flow's run pauses at its screen node and the resume completes it", + "oracle": "network", + "verify": "capture the trigger POST and the /runs/:runId/resume POST; run detail shows paused→completed", + "evidence": "network trace + run reads" + }, + { + "clause": "build ratchet: every Flow.type enum value is classified with a live runtime in the trigger-conformance ledger", + "oracle": "test", + "verify": "run packages/qa/dogfood/test/flow-trigger-conformance.test.ts — it rediscovers the enum from flow.zod.ts and fails on any unclassified type", + "evidence": "test output" + } + ], + "negative": [ + "anonymous POST /api/v1/automation/:name/trigger must be denied (401) with NO run row created — a 2xx or a run row is a FAIL (pinned by packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts)", + "an intake POST with a wrong or missing HMAC signature must be refused (not 202) and create no task — a silent 202 on a bad signature is a FAIL" + ], + "traps": [ + "seed-data-thin", + "dispatcher-vs-hono-route" + ], + "automated": { + "kind": "test", + "ref": "packages/qa/dogfood/test/flow-trigger-conformance.test.ts" + }, + "source": [ + "packages/spec/src/automation/flow.zod.ts (Flow.type enum: autolaunched|record_change|schedule|screen|api)", + "packages/qa/dogfood/test/flow-trigger-conformance.ledger.ts (one enforced row per type, each with its runtime + proof)", + "packages/triggers/trigger-api/src/plugin.ts (HOOKS_PATH /api/v1/automation/hooks/:flowName/:hookId)", + "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow #3427, ScheduledDigestFlow, InboundTaskWebhookFlow)", + "packages/spec/src/automation/execution.zod.ts (ExecutionLogSchema.trigger)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — trigger matrix derived from the Flow.type enum and its ADR-0060 D5 conformance ledger; time_relative added as the declarative sweep the schedule runtime hosts", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "automation.flow-error-handling", + "title": "A failing node inside try_catch is handled (catch region, $error binding); outside it fails the run loudly", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_resilient_sync — try region POSTs https://api.example.com/v1/tasks (unroutable from the isolated env, so the failure is deterministic); retry {maxRetries:3, backoffMs:1000, backoffMultiplier:2, maxRetryDelayMs:10000}; catch region writes sync_status/sync_error onto the task with the caught $error", + "a writable/scratch package to author the UNPROTECTED probe flow into (the showcase itself ships read-only)" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "handled case: POST a showcase_task and PATCH status→'done' — showcase_resilient_sync runs, its try-region http node fails against the unroutable host, retries, then the catch region runs", + "GET /api/v1/automation/showcase_resilient_sync/runs/:runId — record overall status, the try step's status/error/regionKind, the catch step's status/regionKind, and the run duration", + "re-read the task over /api/v1/data/showcase_task/:id — sync_status and sync_error", + "unhandled case: POST /api/v1/automation a minimal autolaunched probe flow (start → http POST to the same unroutable URL, NOT wrapped in try_catch → end) in the scratch package; trigger it via POST /api/v1/automation//trigger", + "GET the probe run: overall status and the run-level error", + "open the designer Runs panel for both runs; screenshot the failed step marked with its error and the catch-body nesting", + "capture the server log lines for the probe run's failure" + ], + "acceptance": [ + { + "clause": "handled: the run completes (status=completed) — the failure was absorbed by the container, not the run", + "oracle": "api", + "verify": "run detail: status=completed; the http step inside the try region records status=failure with regionKind='try'; the catch-region update_record step records status=success with regionKind='catch'", + "evidence": "run-detail read" + }, + { + "clause": "the caught error binds to $error and lands in data: the task carries sync_status='failed' and a non-empty sync_error message", + "oracle": "api", + "verify": "task read after the run: sync_status='failed', sync_error interpolated from {$error.message}", + "evidence": "task read" + }, + { + "clause": "the retry policy actually ran before the catch: the failure is not instantaneous", + "oracle": "api", + "verify": "the run/step duration is at least the first backoff delay (>= ~1s per backoffMs:1000), evidencing at least one retry before the catch — note this oracle's weakness (duration, not a retry counter) in the evidence", + "evidence": "durationMs from the run detail" + }, + { + "clause": "unhandled: the probe run terminates status=failed with the run-level error populated", + "oracle": "api", + "verify": "probe run detail: status=failed (ExecutionStatus), error carries the http failure — NOT completed, NOT an empty error", + "evidence": "probe run-detail read" + }, + { + "clause": "both failures surface in the designer Runs panel: the failed step marked with its error message, catch-body steps nested under the container", + "oracle": "screenshot", + "verify": "Runs panel screenshots for both runs — the panel renders run/step errors (string run-level, {code,message} step-level) and nests region steps", + "evidence": "two Runs panel screenshots" + }, + { + "clause": "the unhandled failure is loud in the server log", + "oracle": "log", + "verify": "an ERROR-level line naming the probe flow/run accompanies the failed run", + "evidence": "log excerpt" + } + ], + "negative": [ + "an unhandled node failure that leaves its run status=completed, or leaves the run-level error empty, is a FAIL — a dead outbound call reporting success is the inert-automation failure shape (#1887)", + "a catch region that runs when the try did NOT fail is a FAIL of the container semantics — check the catch steps are absent from a successful run" + ], + "traps": [ + "wrong-panel", + "single-datapoint" + ], + "source": [ + "examples/app-showcase/src/automation/flows/index.ts (ResilientSyncFlow, ADR-0031 try/catch/retry; canonical retry keys #4661)", + "packages/spec/src/automation/control-flow.zod.ts (TryCatchConfigSchema)", + "packages/spec/src/automation/execution.zod.ts (ExecutionStatus 'failed'; step status/error; regionKind)", + "objectui packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx (run-level string error vs step-level {code,message})", + "packages/runtime/src/route-ledger.ts (POST /automation — automation.create)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — splits handled (try_catch) vs unhandled failure into one contrast item with API + panel + log oracles", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "automation.screen-flow-roundtrip", + "title": "Screen flow round-trip: action trigger → paused run → rendered dialog → resume with inputs → persisted write", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the Tasks list row action showcase_bulk_reassign wired to showcase_reassign_wizard (type screen, runAs user; screen node 'collect' with required field new_assignee; downstream update_record)" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin; open /apps/showcase_app/showcase_task", + "start capturing POSTs to /api/v1/automation/**", + "open the first row's action menu ([data-testid=row-action-trigger]) and click row-action-showcase_bulk_reassign", + "record the trigger POST; GET /api/v1/automation/showcase_reassign_wizard/runs to find the new run — status must be paused", + "GET /api/v1/automation/showcase_reassign_wizard/runs/:runId/screen and record the served screen contract", + "the console renders the screen as a dialog: screenshot it (heading 'New Assignee')", + "fill #ff-new_assignee with a unique value and Submit; record the resume POST body", + "re-read the task over /api/v1/data/showcase_task/:id — the assignee must equal the submitted value", + "negative probe: trigger the action again on another row, then POST /runs/:runId/resume directly with an EMPTY inputs bag", + "cancel-mid-flow probe: trigger the action on a fresh row to open the FlowRunner dialog, note its runId (GET /runs — the newest paused run), then DISMISS the dialog via its Cancel button (or the dialog close) WITHOUT submitting; do NOT POST resume", + "after the cancel: GET /api/v1/automation/showcase_reassign_wizard/runs/:runId for that run, then GET /runs/:runId/screen and POST /runs/:runId/resume with valid inputs to prove it is still resumable, and re-read the target task" + ], + "acceptance": [ + { + "clause": "the flow action triggers a run that pauses at the screen node", + "oracle": "network", + "verify": "a POST matching /automation/[^/]+/trigger was issued and the run reads status=paused at node 'collect'", + "evidence": "network trace + run read" + }, + { + "clause": "GET …/runs/:runId/screen serves the persisted screen contract, including required on new_assignee", + "oracle": "api", + "verify": "the screen response lists the field new_assignee with required=true (the durable screen_json half of the contract)", + "evidence": "screen API read" + }, + { + "clause": "the console renders the paused screen as a dialog on the triggering page", + "oracle": "screenshot", + "verify": "dialog visible with the screen's heading and input — screenshot before any DOM assertions", + "evidence": "dialog screenshot" + }, + { + "clause": "Submit POSTs the resume route with the collected inputs and the run completes", + "oracle": "network", + "verify": "POST …/runs/:runId/resume carries {inputs:{new_assignee:}}; run re-read shows status=completed", + "evidence": "resume request body + run read" + }, + { + "clause": "the downstream update_record persisted: the task's assignee equals the submitted value on an authoritative read", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_task/:id shows the new assignee (not just the refreshed grid cell)", + "evidence": "task read" + }, + { + "clause": "a resume missing the required screen input is refused with 400 and the run STAYS paused", + "oracle": "api", + "verify": "the empty-inputs resume answers 400 (screen-resume validation); the run re-reads as paused, and a later valid resume still works", + "evidence": "rejected resume response + run reads" + }, + { + "clause": "cancelling the screen dialog mid-flow closes the dialog but does NOT consume the durable suspension: the run is parked (still paused at 'collect'), not orphaned or cancelled, and remains resumable", + "oracle": "api", + "verify": "FlowRunner's Cancel/close only calls onClose (setScreenFlow(null)) — it POSTs nothing, so GET /runs/:runId re-reads status=paused at node 'collect'; a follow-up GET /runs/:runId/screen still serves the contract and a POST /runs/:runId/resume with valid inputs completes it (status=completed) and the downstream update_record lands — proving the parked run was resumable, never an orphan", + "evidence": "post-cancel run read (paused) + the later successful screen read/resume + task read" + } + ], + "negative": [ + "a resume with the required new_assignee absent that answers 2xx or completes the run is a FAIL — the screen contract's required flag must be enforced server-side, not only by the dialog", + "a cancel that leaves the run status=cancelled/completed, deletes the paused row, or silently fires the downstream update_record is a FAIL — dismissing the dialog must not consume the durable suspension (the run must stay resumable); equally, a cancel that makes the run unresumable (a later valid resume 404s 'no suspended run') is a FAIL" + ], + "traps": [ + "automation-input", + "hydration-race" + ], + "automated": { + "kind": "e2e", + "ref": "objectui e2e/live/screen-flow.spec.ts" + }, + "source": [ + "objectui e2e/live/screen-flow.spec.ts (framework#3528 — the trigger → dialog → resume → refresh seam)", + "objectui packages/app-shell/src/views/FlowRunner.tsx (onClose = dismiss without POSTing resume; the durable suspension is untouched, so the paused run stays resumable)", + "examples/app-showcase/src/automation/flows/index.ts (ReassignWizardFlow)", + "packages/services/service-automation/src/builtin/screen-resume-validation.test.ts + src/screen-input-contract.ts (400 on missing required inputs)", + "packages/runtime/src/route-ledger.ts (resume + getScreen routes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — promotes the objectui live e2e's seam to a ledger item and adds the server-side required-input negative", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "added the FlowRunner cancel-mid-flow clause — dismissing the screen dialog parks the run (still paused, resumable) rather than orphaning/cancelling it; API run state is the oracle", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "automation.durable-suspend-restart", + "title": "Suspended runs persist to sys_automation_run, survive a cold restart, and resume — including nested (linked-run) pauses", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a FILE-backed database (durability is structurally unreachable on the in-memory store — record the db path in the run env)", + "showcase_task_follow_up (wait timer PT1M) and showcase_project_closure → showcase_closure_signoff (approval inside subflow: the nested durable pause pair)" + ] + }, + "steps": [ + "boot showcase isolated against a file DB (dogfood §0); sign in as the dev admin", + "POST a showcase_task — showcase_task_follow_up suspends at its wait node (timer PT1M)", + "PATCH a showcase_project status→'completed' — showcase_project_closure parks at its subflow node and showcase_closure_signoff parks at its approval node (linked runs)", + "read the paused rows from sys_automation_run over the data API by run id: status, flow name, suspended node, and the rehydration payload", + "stop the server process entirely; cold-boot a second server over the SAME database file", + "re-read the same sys_automation_run rows — still present, still paused", + "resume path 1: let the wait timer elapse (the job service schedules a one-shot resume) and confirm the follow-up notify landed", + "resume path 2: POST /api/v1/approvals/requests/:id/approve on the closure sign-off — the child resumes, bubbles its decision output, and the parent completes", + "confirm the paused rows were CONSUMED on resume (replaced by run history rows), not left behind" + ], + "acceptance": [ + { + "clause": "each suspend persists a paused sys_automation_run row carrying everything a rehydration needs", + "oracle": "api", + "verify": "row read by id: status=paused, flow name, suspended nodeId, serialized variables/continuation present and non-empty", + "evidence": "row reads" + }, + { + "clause": "the paused rows and ordinary records survive a literal cold boot over the same database file", + "oracle": "api", + "verify": "post-restart reads return the identical paused rows (and the seeded data) — the #4518 wasm-driver class of loss (writes never reaching disk) shows up exactly here", + "evidence": "pre/post-restart row reads" + }, + { + "clause": "the timer wait resumes without manual intervention after restart and the downstream notify executes", + "oracle": "api", + "verify": "after the PT1M timer elapses on the NEW process: run status=completed and the reminder notification/inbox row exists for the assignee", + "evidence": "run read + notification read" + }, + { + "clause": "nested pause: the child parks at its approval node and the parent parks at its subflow node, correlated to the child run; the single approval decision completes BOTH and bubbles the child's decision output into the parent", + "oracle": "api", + "verify": "before: two paused runs (parent at 'signoff', child at 'ask_signoff'); after the approve: both completed, and the parent's notify carries {signoffResult.decision}", + "evidence": "before/after reads of both runs + the owner notification" + }, + { + "clause": "resume consumes the paused row: the pause row is gone and a run history row remains in its place", + "oracle": "api", + "verify": "post-resume: the paused-row id no longer reads as paused; the run's history row exists with terminal status", + "evidence": "row reads" + } + ], + "negative": [ + "a paused run that vanishes after restart — or a resume that reports success while the paused row still reads paused — is a FAIL against durability; both were real (#4420: every persist failed into an unread warn while the pause reported success)" + ], + "traps": [ + "stale-dist", + "seed-data-thin" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts" + }, + "source": [ + "packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts (#4470 · #4420 · #4518)", + "examples/app-showcase/src/automation/flows/index.ts (TaskFollowUpFlow, ProjectClosureFlow/ClosureSignoffSubflow — nested durable pause, linked-runs model)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — promotes the #4470 durable-suspend proof to a ledger item and adds the nested linked-run pair", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "automation.connector-dispatch-matrix", + "title": "connector_action dispatches through every registered connector kind, and the registry feeds the designer pickers", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "plugin connectors rest + slack (objectstack.config.ts) and declarative instances showcase_status_api (provider rest) + showcase_mcp_tools (provider mcp, in-repo stdio fixture server) from src/system/connectors/", + "the three dispatching flows: showcase_task_completed_rest_ping (rest), showcase_declarative_connector_ping (declarative rest, ADR-0097), showcase_mcp_connector_echo (declarative MCP, #3056)" + ], + "knownGaps": [ + "slack DELIVERY is not assertable on stock fixtures — TaskCompletedSlackFlow points at a placeholder channel with no real bot token, so the slack variant is registry-enumeration only" + ] + }, + "variants": [ + "rest (plugin)", + "slack (plugin, enumeration only)", + "showcase_status_api (declarative rest)", + "showcase_mcp_tools (declarative MCP)" + ], + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "GET /api/v1/automation/connectors — record the descriptor list and each connector's action list (the MCP instance's actions must come from its tools/list handshake)", + "POST a showcase_task — fires showcase_declarative_connector_ping and showcase_mcp_connector_echo on create", + "PATCH the task status→'done' — fires showcase_task_completed_rest_ping", + "GET each of the three flows' newest run detail; capture the connector step's recorded request and response", + "open one connector_action node in the Studio flow-designer; screenshot the connector → action → input pickers and capture the network call that fills them", + "negative probe: author a scratch flow whose connectorConfig names connectorId 'nope_connector'; trigger it and read the run" + ], + "acceptance": [ + { + "clause": "the registry enumerates all four variants with their action lists", + "oracle": "api", + "verify": "GET /api/v1/automation/connectors lists rest, slack, showcase_status_api, showcase_mcp_tools; the MCP entry's actions include echo_upper", + "evidence": "connectors API read" + }, + { + "clause": "per-variant: each dispatch-capable variant's flow run captures the outbound call AND its response — rest ping and declarative ping both record GET /api/v1/health → {status:'ok'}; the MCP echo records structuredContent.upper === 'OBJECTSTACK' on the run output", + "oracle": "api", + "verify": "run-detail reads for the three flows: connector step success with the captured request/response; the MCP run's output variable echo.structuredContent.upper equals 'OBJECTSTACK'", + "evidence": "three run-detail reads" + }, + { + "clause": "the declarative path is metadata-only end to end: nothing registered showcase_status_api / showcase_mcp_tools in code — the provider materialized them at boot (ADR-0097)", + "oracle": "api", + "verify": "the two instances exist in the registry read AND dispatch in runs, while src/system/connectors declares them as pure metadata (cite the file in evidence)", + "evidence": "registry read + run reads + source citation" + }, + { + "clause": "the designer's connector/action/input pickers are fed by the live registry endpoint", + "oracle": "network", + "verify": "opening the node panel issues GET /api/v1/automation/connectors and the rendered options match the response", + "evidence": "network trace + panel screenshot" + } + ], + "negative": [ + "a connector_action naming an unregistered connectorId must FAIL its step with a named 'connector … not registered'-class error — a silent no-op success is a FAIL (the retired logger-backed stubs delivered nothing while reporting success, #4343)" + ], + "traps": [ + "stale-console-bundle", + "single-datapoint" + ], + "source": [ + "examples/app-showcase/src/automation/flows/index.ts (TaskCompletedRestPingFlow, ShowcaseDeclarativeConnectorPingFlow, ShowcaseMcpConnectorEchoFlow)", + "examples/app-showcase/objectstack.config.ts (ConnectorRestPlugin/ConnectorSlackPlugin/ConnectorMcpPlugin + declarative connectors)", + "packages/runtime/src/route-ledger.ts (GET /automation/connectors)", + "ADR-0097 (provider-bound declarative connector instances)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — connector dispatch matrix over the plugin / declarative-rest / declarative-MCP kinds the showcase seeds, with the unregistered-id negative", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "automation.flow-runs-page-test-trigger", + "title": "The developer Flow Runs page triggers a run with typed inputs and drives a screen flow's pause to completion (no orphaned paused rows)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_batch_reminders (examples/app-showcase/src/automation/flows/index.ts BatchRemindersFlow) — an autolaunched loop flow declaring one INPUT variable `tasks` (type list, isInput:true), so the Test Run panel renders a JSON textarea for it", + "showcase_reassign_wizard (ReassignWizardFlow, type screen) declaring input variables recordId + new_assignee (text) — executing it from this page returns {status:'paused', runId, screen} and the page hands the pause to FlowRunner" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "open the developer Flow Runs PAGE (apps/console FlowRunsPage) at /apps/showcase_app/component/developer/flow-runs (console route developer/flow-runs) — NOT the Studio flow-designer Runs preview", + "typed-input run: select 'Batch Task Reminders (Loop)' (showcase_batch_reminders); the Test Run panel shows the `tasks` input as a JSON `list` textarea; paste three task-shaped objects [{id,title,owner}×3] and click 'Run Flow'", + "record the trigger POST (client.automation.execute → POST /api/v1/automation/showcase_batch_reminders/trigger) and screenshot the Result envelope; confirm a fresh row appeared in the Recent Runs panel (client.automation.listRuns → GET /automation/showcase_batch_reminders/runs)", + "GET /api/v1/automation/showcase_batch_reminders/runs and match the newest run id/status against the panel row", + "screen-flow run: select 'showcase_reassign_wizard'; fill recordId (a real showcase_task id) + new_assignee, click 'Run Flow' — the Result reads 'Waiting for input' (status=paused) and the FlowRunner dialog opens on THIS page", + "screenshot the FlowRunner dialog; fill #ff-new_assignee and Submit; record the resume POST (POST /runs/:runId/resume)", + "GET /api/v1/automation/showcase_reassign_wizard/runs — the driven run reads status=completed; count the paused rows for this flow before vs after and assert the count did not grow (the framework#3528 orphaned-paused regression)", + "re-read the target task over /api/v1/data/showcase_task/:id — its assignee equals the submitted value", + "negative probe: run the screen flow again but DISMISS the FlowRunner without submitting; confirm the page offers a 'Continue run' affordance (the durable pause is reopenable) rather than losing the run" + ], + "acceptance": [ + { + "clause": "a typed-input flow triggers from the page: the `tasks` list input is rendered and editable, Run POSTs the trigger route, and the Result envelope reports a completed run", + "oracle": "network", + "verify": "capture POST /api/v1/automation/showcase_batch_reminders/trigger carrying {params:{tasks:[…3…]}}; the Result JsonBlock shows the run completed (or the loop body executed 3×) — not an error envelope", + "evidence": "trigger request/response + Result screenshot" + }, + { + "clause": "the triggered run appears as a history row in the Recent Runs panel and that row matches the API", + "oracle": "api", + "verify": "GET /automation/showcase_batch_reminders/runs returns a newest run whose id + status equal the panel's freshest row (a row the panel invented that the API does not list is a rendering fault; a run the API lists that the panel omits is a refresh fault)", + "evidence": "runs API read + Recent Runs screenshot side by side" + }, + { + "clause": "a SCREEN flow executed from this page pauses and the pause is handed to FlowRunner — the page opens the interactive dialog instead of dumping the {status:'paused'} envelope and stopping", + "oracle": "screenshot", + "verify": "after Run, the Result reads 'Waiting for input' and the FlowRunner dialog renders on the page (the framework#3528 fix: FlowTestRunner sets screenFlow when res.status==='paused' && res.screen && res.runId)", + "evidence": "dialog screenshot with the page behind it" + }, + { + "clause": "driving the FlowRunner dialog to Submit resumes and completes the run", + "oracle": "network", + "verify": "Submit POSTs /api/v1/automation/showcase_reassign_wizard/runs/:runId/resume with the collected inputs; the run re-reads status=completed and the downstream update_record landed on the task", + "evidence": "resume request body + run read + task read" + }, + { + "clause": "no orphaned paused row: after the screen flow is driven to completion, the run reads completed and the flow's paused-row count did not grow — the exact framework#3528 regression the page fix closes", + "oracle": "api", + "verify": "GET /automation/showcase_reassign_wizard/runs before and after: the run that was paused is now completed and the number of runs left in status=paused is unchanged (each test run of a screen flow used to strand a paused row)", + "evidence": "before/after runs reads with the paused-row tally" + } + ], + "negative": [ + "a screen-flow test run that dumps the {status:'paused'} JSON envelope and stops — no dialog, no way to finish it — leaving a paused run stranded is a FAIL: that is the framework#3528 regression this page's FlowRunner hand-off exists to prevent", + "citing the Studio flow-designer Runs preview (metadata-admin FlowRunsPanel) as this item's surface is a wrong-panel FAIL — this item is the developer:flow-runs PAGE (trigger + inline history), which renders steps FLAT; the nested step-tree lives in automation.flow-run-step-nesting" + ], + "traps": [ + "wrong-panel", + "hydration-race", + "automation-input" + ], + "source": [ + "objectui apps/console/src/pages/developer/FlowRunsPage.tsx (FlowTestRunner + inline FlowRunsPanel; framework#3528 — hands a paused screen run to FlowRunner so it no longer orphans a paused row)", + "objectui packages/app-shell/src/views/FlowRunner.tsx (the shared screen runner reused here)", + "objectui apps/console/src/AppContent.tsx (Route path 'developer/flow-runs') + registerDeveloperComponents.tsx (ref 'developer:flow-runs')", + "packages/runtime/src/route-ledger.ts (POST /automation/:name/trigger, GET /automation/:name/runs, POST /automation/:name/runs/:runId/resume)", + "examples/app-showcase/src/automation/flows/index.ts (BatchRemindersFlow tasks input, ReassignWizardFlow screen)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — pins the developer Flow Runs page's typed-input trigger + the framework#3528 screen-flow hand-off (paused run driven to completion, no orphaned row); distinct from flow-run-step-nesting which reads the designer's Runs preview", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "automation.flow-toggle-kill-switch", + "title": "Toggling a record-change flow OFF is a runtime kill switch — the mutation that fired it produces no new run; ON restores firing", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_urgent_task_alert (examples/app-showcase/src/automation/flows/index.ts UrgentTaskAlertFlow) — the only type:'record_change' flow, bound via record-after-write and firing on a showcase_task created with priority='urgent' (previous==null, #3427)" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0); sign in as the dev admin", + "baseline: POST /api/v1/data/showcase_task with priority='urgent'; GET /api/v1/automation/showcase_urgent_task_alert/runs and record the run count (one new run for this create)", + "read the pre-toggle runtime state: GET /api/v1/automation/_status (getFlowRuntimeStates) — showcase_urgent_task_alert enabled=true, bound=true", + "toggle OFF: POST /api/v1/automation/showcase_urgent_task_alert/toggle with {enabled:false} (client.automation.toggle → engine.toggleFlow → deactivateFlowTrigger)", + "re-read /_status: enabled=false and the record_change trigger unbound (bound=false)", + "POST another priority='urgent' showcase_task; GET the runs list again — assert NO new run row was added (the kill switch)", + "toggle ON: POST /api/v1/automation/showcase_urgent_task_alert/toggle {enabled:true}; re-read /_status (enabled=true, bound=true again)", + "POST a third priority='urgent' showcase_task; GET the runs list — a new run row appears again", + "negative probe (unknown flow): POST /api/v1/automation/does_not_exist/toggle {enabled:false} — expect a not-found error, no state change" + ], + "acceptance": [ + { + "clause": "baseline: an urgent task created BEFORE any toggle fires the flow exactly once", + "oracle": "api", + "verify": "GET /automation/showcase_urgent_task_alert/runs after the first urgent create shows one new run whose trigger records the record_change mutation on that task", + "evidence": "runs list + the created task id" + }, + { + "clause": "toggle OFF is a kill switch: after {enabled:false}, a mutation that used to fire the flow produces NO new run row", + "oracle": "api", + "verify": "the runs list count is IDENTICAL before and after the second urgent create — assert the ABSENCE of a new run, not merely that a run 'looks skipped'", + "evidence": "before/after runs reads bracketing the second create" + }, + { + "clause": "the runtime state mirrors the toggle: /_status reports enabled=false and the record_change trigger unbound while off, enabled=true + bound while on", + "oracle": "api", + "verify": "GET /api/v1/automation/_status (getFlowRuntimeStates) for showcase_urgent_task_alert flips enabled/bound across the OFF and ON toggles — the toggle unbinds the trigger, it does not merely guard execute()", + "evidence": "the three /_status reads" + }, + { + "clause": "toggle ON restores firing: after {enabled:true}, the next urgent task fires the flow again", + "oracle": "api", + "verify": "the runs list gains exactly one new run after the third urgent create (and none was added while off) — firing resumed only after the ON toggle", + "evidence": "runs list after the re-enable" + } + ], + "negative": [ + "a new run row appearing while the flow is toggled OFF is a FAIL — the kill switch must unbind the trigger (deactivateFlowTrigger), not just guard the run; a run that fired anyway means the event source was never detached", + "a toggle that answers 2xx while /_status still reports enabled/bound unchanged is a FAIL — the reported state must match the enforced state", + "toggling an unknown flow that answers 2xx (rather than not-found) is a FAIL" + ], + "traps": [ + "seed-data-thin", + "dispatcher-vs-hono-route" + ], + "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 (toggleFlow → flowEnabled + activateFlowTrigger/deactivateFlowTrigger; getFlowRuntimeStates enabled/bound)", + "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow — the record_change flow, #3427)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — pins the /toggle runtime kill switch on a record_change flow: OFF unbinds the trigger so the firing mutation produces no run, ON restores it; runs list + runtime state as oracles", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json new file mode 100644 index 0000000000..2868a357a8 --- /dev/null +++ b/docs/qa/platform-checklist/areas/cli.json @@ -0,0 +1,511 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "cli", + "title": "CLI — os dev/build/migrate/verify boot & exit-code contracts, scaffold first-run, flag/command error UX", + "items": [ + { + "id": "cli.dev-boot-contract", + "title": "os dev boots to healthy with a loginable seeded admin, honest DB selection, reported port shifts, and a staleness warning", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "cli", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a free non-default port (lsof -nP -iTCP: -sTCP:LISTEN empty first — dogfood skill §0) and a scratch DB path under /tmp//" + ] + }, + "steps": [ + "from examples/app-showcase run `objectstack dev --ui --seed-admin -p -d file:/tmp//data.db` and capture the full boot log (Environment ID / Artifact / Database key-values print at boot)", + "poll `curl -s -m3 -o /dev/null -w '%{http_code}' http://localhost:/api/v1/health` until 200, then GET /api/v1/ready; open /_console/ and sign in as admin@objectos.ai / admin123", + "stop; restart the SAME command against the SAME DB but with `--admin-password changed99`; attempt login with the ORIGINAL password (the seed is empty-DB-only and must not overwrite)", + "stop; in a scratch dir with NO objectstack.config.ts and no --artifact, run `os dev; echo $?` and capture the message", + "back in the app dir with the server DOWN, touch a src file so it is newer than dist/objectstack.json, boot WITHOUT --compile, and capture the staleness warning block", + "with instance A still running, boot instance B on the same requested port and capture the '↪ server bound to port (requested )' line", + "run once with NONE of --database/--fresh/env-db set and record the printed Database line (must be the project-anchored file:/.objectstack/data/dev.db); then run with `--fresh` and record the '🧪 Fresh OS_HOME' tempdir line and its deletion on exit", + "capture `echo $?` after every terminated invocation" + ], + "acceptance": [ + { + "clause": "the boot reaches health: /api/v1/health answers 200 and /api/v1/ready succeeds, with the console served at /_console when --ui is passed", + "oracle": "api", + "verify": "the health poll flips to 200 within the probe budget; ready returns success; /_console/ serves the login page", + "evidence": "curl outputs + console screenshot" + }, + { + "clause": "--seed-admin provisions the FIXED well-known dev admin (admin@objectos.ai / admin123) loginable via the real auth endpoint, and the seed is idempotent — a later boot with a different --admin-password does NOT overwrite the existing account", + "oracle": "api", + "verify": "login succeeds with the original credentials after the changed-password restart (flag contract: 'only acts on a zero-user DB, never overwrites an existing account')", + "evidence": "the two auth responses" + }, + { + "clause": "DB selection honors the resolveDefaultDevDbUrl matrix: with nothing chosen, dev defaults to the PERSISTENT project-anchored sqlite file (.objectstack/data/dev.db) — never the serve default of :memory: that wipes work on restart", + "oracle": "log", + "verify": "the printed Database key-value per variant matches the matrix (default file path; -d url; --fresh tempdir; env url; memory driver imposes no file default)", + "evidence": "the per-variant Database boot lines" + }, + { + "clause": "--fresh isolates OS_HOME-keyed state in an auto-deleted tempdir — and the evidence must NOT claim isolation for app-declared cwd-relative paths, which survive by documented design (#5594, e.g. the showcase-external datasource file)", + "oracle": "log", + "verify": "the Fresh OS_HOME line names a tempdir; the dir is gone after exit; any surviving .objectstack/data/showcase_external.db is annotated as the documented #5594 carve-out, not filed as a bug", + "evidence": "boot line + post-exit directory listings" + }, + { + "clause": "a busy requested port auto-shifts AND is reported — the actually-bound port is printed, never silently different", + "oracle": "log", + "verify": "instance B prints '↪ server bound to port (requested )' (the IPC objectstack:listening channel exists so the parent can print the truth)", + "evidence": "the log line + a health probe on the actual port" + }, + { + "clause": "a stale artifact is called out at boot: when dist/objectstack.json is older than the sources, the boot warns loudly, names the newest source and the remedy — and still boots (warn, never gate — #5148)", + "oracle": "log", + "verify": "the '⚠ … is OLDER than your sources — this boot serves the STALE build' block prints with the newest-source path and the fix line; the server still comes up", + "evidence": "the warning block + subsequent healthy boot" + }, + { + "clause": "negative: no config and no artifact exits 1 with the remedy ('Run in a directory with objectstack.config.ts, pass --artifact , or run from the monorepo root.') — never a hang, never a silent 0", + "oracle": "log", + "verify": "echo $? prints 1 and stderr carries the remedy line", + "evidence": "captured stderr + exit code" + } + ], + "negative": [ + "a dev boot that wipes an existing dev DB when the user chose nothing (a regression to the :memory: serve default) is the FAIL the persistent default exists for", + "an auto-shifted port that is not reported (URL printed for the requested port while the server bound elsewhere) is a FAIL" + ], + "variants": [ + "default: file:/.objectstack/data/dev.db (persistent, imposed only when nothing else chosen)", + "--database (explicit; no default imposed)", + "--fresh (ephemeral tempdir OS_HOME; implies --seed-admin)", + "OS_DATABASE_URL / DATABASE_URL env (env wins over the default)", + "--database-driver memory / OS_DATABASE_DRIVER=memory (explicit in-memory; no file default)" + ], + "traps": ["stale-dist", "stale-console-bundle", "shared-browser-tab"], + "source": [ + "packages/cli/src/commands/dev.ts (resolveDefaultDevDbUrl matrix; --fresh coverage note #5594; seed-admin idempotency contract; IPC bound-port report; #5148 staleness warning + rebuild-restart coordinator)", + ".claude/skills/dogfood-verification/SKILL.md §0–§1 (port isolation, health probe, fixed admin creds, /_console layout)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new area item: the os dev boot contract read directly out of dev.ts (DB-selection matrix as variants, seed idempotency, port-shift reporting, #5148 staleness warning, #5594 fresh-isolation carve-out) plus the dogfood skill's boot shapes", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "cli.build-own-contract", + "title": "os build's own contract: exit 0/1 only, located errors for schema and author-time rule failures, artifact + stats output, warnings never flip the exit", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "build", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a scratch copy of a known-good app config (scaffolded blank app or a worktree copy of examples/app-showcase) so deliberate breakage never touches shared fixtures" + ] + }, + "steps": [ + "on the clean config run `os build; echo $?` and capture the summary (metadata stats, Artifact path/size)", + "run `os build --json` and capture the success payload", + "break the schema: give one field an impossible shape (e.g. delete its type) and run `os build; echo $?` — capture the located Zod error", + "break an author-time rule: author a flow whose Approval node approver expression does not parse (the #4409 worked example that once built green while os lint rejected it) and run `os build; echo $?`", + "re-run both failing builds with --json and capture the failure payloads", + "restore the config, add one UNDECLARED authoring key (the #3786 advisory class) and run `os build; echo $?` — the warning prints, the build passes", + "confirm `os build` and `os compile` produce identical behavior on the same input (build is the documented alias)" + ], + "acceptance": [ + { + "clause": "success: exit 0, dist/objectstack.json written, and the summary reports metadata stats + artifact size; --json answers { success: true, output, size, stats, warnings, conversions, duration }", + "oracle": "build", + "verify": "echo $? is 0, the artifact file exists and parses, and the --json payload carries the declared keys", + "evidence": "exit code + artifact listing + the JSON payload" + }, + { + "clause": "a schema violation fails the build with exit 1 and a LOCATED error naming the failing path — never a bare 'validation failed'", + "oracle": "build", + "verify": "the formatted Zod error names the object/field path of the deliberate break; echo $? is 1", + "evidence": "the error output + exit code" + }, + { + "clause": "author-time rule failures exit 1 and every finding carries where/message/hint/rule/path — and ALL failing rules report at once, not first-failure-only", + "oracle": "build", + "verify": "the #4409 registry output for the broken-approver fixture shows the located finding with its rule id and hint ('the build is the command that SHIPS' — it must be no weaker than validate/lint)", + "evidence": "the rule-failure output + exit code" + }, + { + "clause": "--json failure and advisory shapes match os validate --json (same warnings/conversions keys) — the #3782 parity class: the two surfaces must not disagree about what an author is told", + "oracle": "log", + "verify": "the failure payload is { success: false, … issues } and success payloads carry warnings + conversions under the same keys validate emits", + "evidence": "the paired --json payloads" + }, + { + "clause": "exit codes are exactly 0 or 1 (the CliExitCode union) — never a count, never a duration", + "oracle": "build", + "verify": "echo $? across all runs is only ever 0 or 1 (the type that pins the #4873 class for every emitJson caller)", + "evidence": "the collected exit codes" + }, + { + "clause": "advisories never flip the exit: the undeclared-authoring-key build warns visibly AND exits 0 — both sides of the warn/fail line", + "oracle": "build", + "verify": "the #3786 warning block prints, the artifact is written, echo $? is 0", + "evidence": "warning output + exit code + artifact" + } + ], + "negative": [ + "exit 0 with no artifact written, or nonzero on the clean config, is a FAIL", + "the DEEP gate content (date-arithmetic formula errors, retired-key tombstones) is api-backend.formula-gates / api-backend.enforce-or-remove-authoring-gates — cite pinned passes there; this item owns only the build's OWN exit/error/output contract" + ], + "traps": ["stale-dist"], + "source": [ + "packages/cli/src/commands/compile.ts (the full gate pipeline: Zod parse, #4409 author-time rule registry, #3786 unknown-key advisory, --json shapes, #3782 conversion-notice parity) + build.ts (alias)", + "packages/cli/src/utils/format.ts (CliExitCode = 0 | 1 — the narrowed exit-code slot)", + "api-backend.formula-gates, api-backend.enforce-or-remove-authoring-gates (gate content — cross-referenced, not duplicated)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: the build command's own contract (exit-code discipline, located schema + rule errors, --json parity, advisory both-sides) read from compile.ts, with the gate-content items cross-referenced instead of re-proven", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "cli.migrate-plan-apply-json", + "title": "os migrate: bare command is a read-only plan, apply is safe-by-default, re-runs are idempotent, and --json exits 0 on success (#4873)", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a scratch app + persistent sqlite DB whose base schema was synced by one prior `os dev` boot (plan itself defers DDL since #3917, so the tables must pre-exist), e.g. -d file:/tmp//migrate.db" + ] + }, + "steps": [ + "boot the scratch app once with `os dev -d file:/tmp//migrate.db` so the base schema exists; stop it", + "add a new field to one object in the config and `os build`; run bare `os migrate` twice — both runs must print the SAME pending add-column plan and mutate nothing", + "run `os migrate plan --json; echo $?` and capture the payload ({ database, managedTables, total, changes, pending })", + "run `os migrate apply --yes; echo $?` and capture the applied list; then `os migrate plan` again (expect in-sync) and `os migrate apply --json --yes; echo $?` (expect message in_sync) — the idempotent re-run", + "remove a field from the config + `os build`; run `os migrate apply --yes` WITHOUT --allow-destructive (the drop must be SKIPPED with a warning), then with `--allow-destructive --yes` (applied)", + "with the dev server booted and holding the DB, run `os migrate apply --yes; echo $?` (expect the busy refusal, exit 1, without --force) and `os migrate plan` (proceeds with a warning — a plan writes nothing either way)", + "run `os migrate apply --json` WITHOUT --yes on a pending change: the payload must say confirmation_required with the pass--yes hint and mutate nothing", + "the #4873 pair: on the scratch DB run `os migrate recorded-by --json; echo $?` and `os migrate resume --json; echo $?`" + ], + "acceptance": [ + { + "clause": "bare `os migrate` is the dry-run plan and NEVER mutates: boot-time DDL is deferred and the artifact seed suppressed (#3917), so two consecutive plans report identical drift and the physical schema is unchanged", + "oracle": "log", + "verify": "the two plan outputs match; a schema dump (or third plan) after them equals the first", + "evidence": "the two plan outputs + schema check" + }, + { + "clause": "plan --json emits the declared shape and exits 0", + "oracle": "log", + "verify": "payload carries database/managedTables/total/changes/pending; echo $? is 0", + "evidence": "payload + exit code" + }, + { + "clause": "#4873 exit-code honesty: EVERY migrate subcommand with --json exits 0 on success — never the elapsed-ms leak (recorded-by/resume once passed timer.elapsed() into the exit-code slot, so the shell saw duration & 0xFF: a different bogus nonzero on every successful run)", + "oracle": "log", + "verify": "echo $? after plan/apply/recorded-by/resume --json successes is exactly 0; the CliExitCode 0|1 type now makes the mistake a compile error", + "evidence": "the collected exit codes per subcommand" + }, + { + "clause": "apply is safe-by-default: a destructive change is SKIPPED with an explicit warning until --allow-destructive, then applied with it — both sides captured", + "oracle": "log", + "verify": "the drop appears under skipped (with the re-run hint) on the first apply and under applied on the --allow-destructive run", + "evidence": "the two apply outputs" + }, + { + "clause": "idempotent re-run: after a successful apply, plan reports in-sync ('nothing to migrate') and apply --json answers message in_sync with exit 0", + "oracle": "log", + "verify": "the post-apply plan prints the in-sync success line; the JSON re-apply payload is { …, message: 'in_sync' } and echo $? is 0", + "evidence": "outputs + exit codes" + }, + { + "clause": "a busy database refuses apply (exit 1) without --force, while plan proceeds with only a warning — the read/write asymmetry is deliberate", + "oracle": "log", + "verify": "apply against the server-held DB exits 1 naming the occupancy; plan against the same DB completes with the busy warning", + "evidence": "both outputs + exit codes" + }, + { + "clause": "--json is non-interactive: a mutating apply without --yes reports confirmation_required + the hint and performs NO change", + "oracle": "log", + "verify": "the payload says confirmation_required, hint 'pass --yes'; a follow-up plan still shows the pending change", + "evidence": "payload + follow-up plan" + } + ], + "negative": [ + "an apply that performs a drop WITHOUT --allow-destructive is the FAIL the safe-by-default split exists for", + "exit 0 from a refused (busy) apply, or ANY nonzero exit from a successful --json run, is a FAIL — exit codes are the load-bearing CI contract here" + ], + "variants": [ + "plan (default of the bare command)", + "apply", + "resume", + "recorded-by", + "meta", + "files-to-references", + "summary-nulls", + "value-shapes" + ], + "traps": ["stale-dist"], + "automated": { "kind": "unit", "ref": "packages/cli/src/utils/format.exit-code.test.ts" }, + "source": [ + "packages/cli/src/commands/migrate/index.ts + plan.ts (#2186 bare-command-is-plan; #3917 enforced never-mutates; occupancy warning) + apply.ts (--allow-destructive / --force / --yes / in_sync)", + "packages/cli/src/utils/format.ts (CliExitCode narrows the emitJson exit slot — the #4873 fix, commit 83df2fd)", + "packages/cli/src/commands/migrate/ (the eight registered subcommands enumerated as variants)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: the migrate topic's read-only/apply/idempotency contract from plan.ts+apply.ts, with #4873 --json exit-code honesty as a load-bearing clause pinned to format.exit-code.test.ts and the subcommand set enumerated from src/commands/migrate/", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "cli.verify-verdict-exit-mapping", + "title": "objectstack verify: verdict vocabulary is closed, exit is nonzero exactly on failure verdicts, and the inconclusive split (needs-fixture/skipped) never fails the run", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": ["examples/app-showcase as the verified app (its config is the stock target)"], + "knownGaps": [ + "no stock fixture ships a GUARANTEED-failing app, so the exit-1 side of the hardFailures mapping must be staged (point --app at a scratch config engineered to produce a fidelity gap or 5xx create) — record how it was staged in the evidence rather than skipping the side silently" + ] + }, + "steps": [ + "from examples/app-showcase run `objectstack verify --json > report.json; echo $?`", + "assert every results[].status in report.json is one of the six declared verdicts, and recompute the summary counts from results[] (they must reconcile)", + "recompute hardFailures = createFailed + readFailed + fidelityGaps (+ rls holes when --rls ran) from the report and compare to the exit code", + "run `objectstack verify --rls --json; echo $?` (the RLS proofs boot a SEPARATE fresh stack by design — unique-constraint collisions with the fidelity phase would silently skip objects)", + "run `OS_TENANCY_POSTURE=isolated objectstack verify --json` and capture either multiTenant:true in the payload or the hard boot error — never a quiet single-org run (#5262)", + "stage the fail side per the knownGap and capture `echo $?` (expect 1) alongside the failing report", + "capture the human-format run too (`objectstack verify`) and check the ✓/✗ banner agrees with the recomputed hardFailures" + ], + "acceptance": [ + { + "clause": "the verdict vocabulary is closed: every per-object status is exactly one of verified | fidelity-gaps | create-failed | read-failed | skipped | needs-fixture", + "oracle": "log", + "verify": "scan results[].status in the JSON report against the six-member union in packages/verify/src/verify.ts", + "evidence": "report.json" + }, + { + "clause": "exit code is nonzero EXACTLY when hardFailures > 0, where hardFailures = createFailed + readFailed + fidelityGaps + rlsHoles — recomputed from the report, never trusted from the banner", + "oracle": "log", + "verify": "echo $? is 0 when the recomputed sum is 0 and 1 when it is positive (the staged failing run)", + "evidence": "exit codes paired with recomputed sums" + }, + { + "clause": "the inconclusive split is honored: a 400 VALIDATION_FAILED on the auto-derived record is classified needs-fixture (a fixture gap, not a platform finding) and does NOT fail the run; a 5xx is create-failed and DOES", + "oracle": "log", + "verify": "needs-fixture and skipped counts are excluded from hardFailures in the report vs exit-code comparison; the classification comment in verify.ts is the contract", + "evidence": "report excerpt showing needs-fixture objects on an exit-0 run" + }, + { + "clause": "--rls runs its proofs on a separate fresh stack and any hole fails the run", + "oracle": "log", + "verify": "the --rls report includes the rls section; rls.summary.holes participates in hardFailures", + "evidence": "the --rls report + exit code" + }, + { + "clause": "#5262 posture honesty: a walled OS_TENANCY_POSTURE (isolated|group) makes verify boot org-scoped, or hard-fail when the enterprise runtime is missing — NEVER a quiet single-org pass that under-verifies ('a verifier that under-verifies reports success it never established')", + "oracle": "log", + "verify": "the payload shows multiTenant:true OR the run errors loudly; multiTenant:false under a walled posture is the pinned regression", + "evidence": "the posture run's payload or error" + } + ], + "negative": [ + "exit 0 while the report carries fidelityGaps > 0 (or any create/read failure) is a FAIL of the verifier's own honesty contract", + "a quiet single-org boot under a walled posture is the #5262 regression (third recurrence of the shape: cloud#1020, #5233) — FAIL" + ], + "variants": [ + "verified", + "fidelity-gaps (hard failure)", + "create-failed (hard failure)", + "read-failed (hard failure)", + "needs-fixture (inconclusive — never fails)", + "skipped (inconclusive — never fails)" + ], + "traps": ["stale-dist", "wrong-persona"], + "automated": { "kind": "unit", "ref": "packages/cli/src/commands/verify-tenancy-posture.test.ts" }, + "source": [ + "packages/cli/src/commands/verify.ts (hardFailures sum, exit contract, resolveVerifyMultiTenant / #5262 / ADR-0105 D1, separate RLS stack rationale)", + "packages/verify/src/verify.ts (the six-member status union; 400-VALIDATION_FAILED→needs-fixture vs 5xx→create-failed classification)", + "packages/verify/src/rls.ts (#1994 class: you can't write what you can't read)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: verdict-to-exit-code mapping read from verify.ts + verify.ts (verdict union), with the inconclusive split and the #5262 posture-honesty negative as load-bearing clauses; the unstageable always-failing fixture recorded as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "cli.scaffold-first-run", + "title": "The published first-run closes: create-objectstack scaffold → install → validate → build → boot → health, with the skills boundary holding", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["new user (published-registry consumer)"], + "fixtures": { + "app": "showcase", + "requires": ["npm registry reachability (the whole point is the PUBLISHED path, not the repo checkout)"], + "knownGaps": [ + "during an RC window the repo version is unpublished and `latest` points at the previous major, so the protocol-major handshake refuses the artifact — that refusal is the ADR-0087 D1 gate WORKING on a skew the fallback introduced (#4894); record it in evidence, do not fail the item for it" + ] + }, + "steps": [ + "in a scratch dir: `npx -y create-objectstack@latest qa-first-run -t blank --skip-skills`", + "cd qa-first-run && npm install --no-fund --no-audit; echo $?", + "npm run validate; echo $? — then npm run build; echo $?", + "boot from the artifact exactly as the workflow does: `npx os start --artifact ./dist/objectstack.json --port 8080 > server.log 2>&1 &`, poll /api/v1/health up to 60s, then `curl -fsS http://localhost:8080/api/v1/ready`", + "repeat scaffold + validate + build across the template matrix (variants) — remote templates always ship build; run validate where the script exists", + "skills boundary probe: in a fresh scaffold run `npx -y skills add /skills --all --copy` and compare the installed set to the curated skills/ catalog; then list repo-root discovery and assert no internal skill (dogfood-verification) surfaces", + "capture server.log for any boot error" + ], + "acceptance": [ + { + "clause": "scaffold → install → validate → build → boot → health closes with every step exit 0, health 200 and ready succeeding", + "oracle": "api", + "verify": "the per-step exit codes are 0 and the two probes succeed against the booted artifact", + "evidence": "step exit codes + curl outputs + server.log" + }, + { + "clause": "every published template validates and builds — per-variant, no template inferred from a sibling", + "oracle": "build", + "verify": "validate (where present) and build exit 0 for each of the six templates", + "evidence": "per-template exit codes" + }, + { + "clause": "the scaffolded artifact boots via os start --artifact: the ADR-0087 D1 protocol-major handshake ACCEPTS a coherent scaffold — and when the RC-window skew applies, it REFUSES with the named engines.protocol mismatch instead of booting wrong", + "oracle": "log", + "verify": "either the healthy boot, or the exact '✗ package … targets protocol ^N … but this runtime is protocol M' refusal recorded as the gate working (#4894)", + "evidence": "server.log excerpt" + }, + { + "clause": "the skills boundary holds: the installed set equals the curated skills/ catalog and repo-root discovery surfaces NO internal skill (the 15.1 third-party-eval leak, pinned in the workflow)", + "oracle": "log", + "verify": "set-equality against the curated catalog; grep for dogfood-verification in the discovery listing comes back empty", + "evidence": "the installed-set diff + discovery listing" + }, + { + "clause": "negative: a server that never becomes healthy within the probe budget is a FAIL carrying server.log — not a retry-until-green", + "oracle": "log", + "verify": "on timeout the run records the failure with the full server.log, mirroring the workflow's '::error::server never became healthy' branch", + "evidence": "server.log on any failure" + } + ], + "negative": [ + "an internal skill appearing in a scaffolded project is the exact leak the boundary step exists for — FAIL", + "a template that builds only from the repo checkout but not from the registry is the #2908 class this whole item guards" + ], + "variants": ["blank", "todo", "compliance", "content", "contracts", "procurement"], + "traps": ["stale-dist"], + "automated": { "kind": "ci", "ref": ".github/workflows/scaffold-e2e.yml" }, + "source": [ + ".github/workflows/scaffold-e2e.yml (#2908 — the scaffold→install→validate→build→boot→health lane, the registry-canary template matrix, the skills-boundary assertions, the #4894 RC-window fallback)", + "packages/create-objectstack (the scaffolder under test)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: the published first-run experience mirrored step-for-step from scaffold-e2e.yml, template matrix as variants, RC-window protocol refusal recorded as gate-working instead of failure", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "cli.flag-command-error-ux", + "title": "Wrong flags and unknown commands error with usage and a nonzero exit — never silently ignored, never executed anyway", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "cli", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": ["any directory — the probes must not depend on a valid app config, since parse errors fire before command bodies run"] + }, + "steps": [ + "`os migrate plan --frobnicate; echo $?` — capture stderr, and assert the command body never ran (no 'Migrate · plan' header, no 'Booting schema stack' line)", + "`os dev --log-level bogus; echo $?` — an enum-valued flag with an out-of-set value", + "`os frobnicate; echo $?` — an unknown top-level command", + "`os environments frobnicate; echo $?` — an unknown subcommand of a registered topic", + "`os --help; echo $?` and `os migrate --help; echo $?` — the help side must exit 0", + "sweep the registered surface: for every variant run `os --help; echo $?` and record exit code + presence of a USAGE block", + "collect all outputs and exit codes into one evidence table" + ], + "acceptance": [ + { + "clause": "an unknown flag errors NAMING the flag (oclif: 'Nonexistent flag: --frobnicate') with a help pointer, exits nonzero (oclif parse errors exit 2) — and the command body never runs", + "oracle": "log", + "verify": "stderr names --frobnicate and points at --help; echo $? is nonzero; the plan header/boot line is absent from the output", + "evidence": "captured stderr + exit code + absence check" + }, + { + "clause": "an out-of-set value for an enum flag errors listing the allowed set, exit nonzero", + "oracle": "log", + "verify": "the --log-level bogus error enumerates debug|info|warn|error|fatal|silent; echo $? is nonzero", + "evidence": "stderr + exit code" + }, + { + "clause": "an unknown command or topic-subcommand errors 'command … not found' with exit nonzero — never treated as a default command (this CLI ships help+plugins only, no not-found suggester: a typo is a hard error, not a did-you-mean prompt)", + "oracle": "log", + "verify": "both the top-level and topic probes error naming the unknown command; echo $? is nonzero for each", + "evidence": "the two stderr captures + exit codes" + }, + { + "clause": "--help exits 0 with a usage block for the root and for EVERY registered command and topic — per-variant, none inferred", + "oracle": "log", + "verify": "the sweep records exit 0 + a USAGE section for all 30 variants", + "evidence": "the sweep's exit-code/usage table" + } + ], + "negative": [ + "a parse error that still executes the command (output shows the command ran after the flag error) is the silent-ignore FAIL this item exists for", + "exit 0 on any unknown-flag/unknown-command probe is a FAIL even if an error message printed" + ], + "variants": [ + "build", + "compile", + "create", + "dev", + "diff", + "doctor", + "explain", + "generate", + "info", + "init", + "lint", + "login", + "logout", + "register", + "serve", + "start", + "test", + "validate", + "verify", + "whoami", + "cloud (topic)", + "data (topic)", + "datasource (topic)", + "db (topic)", + "environments (topic)", + "i18n (topic)", + "meta (topic)", + "migrate (topic)", + "package (topic)", + "plugin (topic)" + ], + "source": [ + "packages/cli/src/commands/ (the 20 top-level commands + 10 topics enumerated as variants — oclif pattern discovery per package.json oclif.commands)", + "packages/cli/package.json (oclif.plugins = help + plugins only — no not-found plugin, so unknown commands hard-error)", + "@oclif/core parse contract (Nonexistent flag / enum FailedFlagValidation / command-not-found, exit 2)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: flag/command error UX with the full registered command surface enumerated from src/commands/ as variants, and never-executed-anyway as the load-bearing negative", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/dashboards.json b/docs/qa/platform-checklist/areas/dashboards.json new file mode 100644 index 0000000000..0ab6bf2173 --- /dev/null +++ b/docs/qa/platform-checklist/areas/dashboards.json @@ -0,0 +1,1041 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "dashboards", + "title": "Dashboards, reports, analytics", + "items": [ + { + "id": "dashboards.strict-widget-rejects-stray-keys", + "title": "A widget authored with a stray/legacy key fails loud, never renders blank", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a WRITABLE package for dashboard drafts — the metadata draft door (PUT /api/v1/meta/dashboard/?mode=draft) accepts a scratch dashboard name, so no showcase file needs touching; if the deployment blocks runtime metadata writes, record that as a fixture requirement" + ] + }, + "steps": [ + "boot the showcase app, sign in as admin, and pick a scratch dashboard name (e.g. qa_stray_key_probe) so no shipped dashboard is mutated", + "for each variant in `variants`, author a dashboard draft via PUT /api/v1/meta/dashboard/qa_stray_key_probe?mode=draft whose single widget carries a valid dataset binding (dataset: showcase_task_metrics, dimensions: ['status'], values: ['task_count']) PLUS the stray key (e.g. \"categoryField\": \"status\")", + "capture the full response status and error body for each variant", + "additionally author one widget with a wrong-layer key INSIDE chartConfig (e.g. chartConfig.dataset or chartConfig.aggregate) — ChartConfigSchema carries per-key guidance for these — and capture that error text too", + "attempt the same stray-key widget through the Studio dashboard designer inspector (browser door) and capture the surfaced error", + "after each rejection, GET /api/v1/meta/dashboard/qa_stray_key_probe and record whether anything was persisted", + "finally author the SAME widget with the stray key removed (pure dataset + dimensions + values) and load it in the browser" + ], + "acceptance": [ + { + "clause": "every legacy inline-analytics key variant is rejected with an error NAMING the offending key", + "oracle": "log", + "verify": "for each of the 11 LEGACY keys enumerated in packages/spec/src/ui/dashboard.zod.ts (the pre-ADR-0021 shape removed at @objectstack/spec 9.0.0), the error body contains the literal key name", + "evidence": "the per-variant error texts, keyed by variant" + }, + { + "clause": "the error points at the expected dataset+dimensions+values shape (ADR-0021), giving the author the fix — not just 'unrecognized key'", + "oracle": "log", + "verify": "error text cites binding a `dataset` and selecting `dimensions`/`values` (the DashboardWidgetSchema error map's prescription)", + "evidence": "error text excerpt" + }, + { + "clause": "a hallucinated key (one that never existed, e.g. `chartFlavour`) is also rejected loudly with the key echoed back and a did-you-mean suggestion where one is close", + "oracle": "log", + "verify": "error names the hallucinated key; the strictObject suggester output is present or absent honestly", + "evidence": "error text" + }, + { + "clause": "wrong-layer keys inside chartConfig (`dataset`, `aggregate`, `objectName`, `drillDown`) are rejected with the guidance naming the surface the key actually belongs to", + "oracle": "log", + "verify": "error for chartConfig.dataset says it is the widget's own key (ADR-0021 sibling of chartConfig); error for chartConfig.drillDown names the react-tier prop, per the guidance map in packages/spec/src/ui/chart.zod.ts", + "evidence": "the two error texts" + }, + { + "clause": "a rejected draft is NOT persisted — the rejection is authoritative, not cosmetic", + "oracle": "api", + "verify": "GET /api/v1/meta/dashboard/qa_stray_key_probe after each rejected PUT returns 404 or the last GOOD revision, never a body containing the stray key", + "evidence": "the GET responses paired with each rejected PUT" + }, + { + "clause": "a well-formed widget with the same data renders — the gate rejects the KEY, not the dataset", + "oracle": "screenshot", + "verify": "the corrected widget (dataset: showcase_task_metrics, dimensions: ['status'], values: ['task_count']) draws a real bar chart in the browser", + "evidence": "screenshot of the corrected widget" + } + ], + "negative": [ + "the old failure mode — silently rendering nothing — must not reproduce for ANY variant; blank-without-error is a FAIL", + "a 2xx on a stray-key draft save is a FAIL even if the dashboard later renders: the strict gate exists at the parse, not the paint" + ], + "variants": [ + "object", + "categoryField", + "categoryGranularity", + "valueField", + "aggregate", + "aggregation", + "rowField", + "columnField", + "xAxisField", + "yAxisFields", + "measures", + "chartFlavour (hallucinated control)" + ], + "traps": [ + "stale-console-bundle" + ], + "source": [ + "#3358 §3 (four stray-key variants all rejected with the named error)", + "packages/spec/src/ui/dashboard.zod.ts (LEGACY key list + strict error map, ADR-0021 single-form cutover)", + "packages/spec/src/ui/chart.zod.ts (ChartConfigSchema wrong-layer guidance: dataset/aggregate/objectName/drillDown)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "dashboards.chart-first-paint", + "title": "Charts draw on first paint — no blank-until-resize", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "stock showcase seed (tasks span every status/priority and multiple created_at months — src/data/seed/index.ts sizes the seed to feed every view)" + ] + }, + "steps": [ + "before touching the browser, establish ground truth: POST the dataset query (or GET /api/v1/data/showcase_task with a status aggregate) and record how many distinct status buckets the seed actually produces", + "cold-load the Chart Gallery dashboard (Showcase app → Analytics → Chart Gallery, dashboard name showcase_chart_gallery) in a fresh tab at a fixed window size", + "screenshot immediately after render settles, WITHOUT resizing the window", + "repeat the cold-load + immediate screenshot on the Delivery Operations dashboard (showcase_ops_dashboard) — its KPI hero row plus comparison/trend charts is the other first-paint composition", + "only after the screenshot confirms rendering, read the DOM to count the recharts SVG bars in Tasks by Status", + "capture the browser console log for the whole load" + ], + "acceptance": [ + { + "clause": "bars/axes are drawn on the first paint, with no window resize", + "oracle": "screenshot", + "verify": "the first-paint screenshot shows a real chart (recharts SVG with bars and axes), not an empty plot area", + "evidence": "first-paint screenshots of both dashboards" + }, + { + "clause": "the drawn chart reflects the multi-bucket seed, not one lonely datapoint — the tick must say what it looks like it says", + "oracle": "api", + "verify": "the bar count in Tasks by Status equals the distinct-status count established from the data API before the browser was opened", + "evidence": "the API bucket count next to the DOM bar count" + }, + { + "clause": "KPI metric tiles on Delivery Operations render numeric values on first paint (not placeholder dashes that never resolve)", + "oracle": "screenshot", + "verify": "the hero-row tiles show numbers consistent with a direct dataset query for the same measures", + "evidence": "screenshot + the comparison query result" + }, + { + "clause": "no chart error and no silent failure: the console log carries no dataset-query error during first paint, and every widget either draws or shows a named error state", + "oracle": "log", + "verify": "browser console capture for the load contains no swallowed widget/query failure; any failed widget shows a visible error, not an empty plot", + "evidence": "console log excerpt" + } + ], + "negative": [ + "a chart area that stays blank until the window is resized is the FAIL this item exists for, even if it draws afterwards" + ], + "traps": [ + "single-datapoint", + "hydration-race" + ], + "source": [ + "#3358 §3 — note its caveat: with thin seeds every widget draws one data point, so the tick 'says less than it looks'; prefer multi-bucket fixtures", + "examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts", + "examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358 with the thin-seed caveat attached", + "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" + } + ] + }, + { + "id": "dashboards.drill-through-range", + "title": "Report drill-through scopes the drilled list to the exact bucket range", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a date-bucketed report with MULTIPLE buckets to drill (blocked historically by #3415 — seed validation silently rejected 4 of 5 projects, leaving single-point charts)", + "the seeded showcase reports with drilldown: true — showcase_hours_by_status (summary), showcase_status_priority_matrix (matrix) — and the Chart Gallery line_created widget (month-bucketed created_at) for the date-bucket case" + ] + }, + "steps": [ + "establish ground truth first: query showcase_task via /api/v1/data with a status aggregate and a created_at month aggregate, recording each bucket's count", + "open the report showcase_hours_by_status (nav 'Hours by Status'), click a status bucket cell, and capture the drilled list's network request", + "open the Chart Gallery dashboard and click a month bucket on the line_created widget (dataset-bound widgets drill through the semantic layer — the drill target and filter derive from the clicked dataset row, per the ADR-0021 note in dashboard.zod.ts)", + "capture the drill request for the month click, including its date bounds", + "count the rows in each drilled list (page through if paginated) and compare to the bucket's aggregate", + "click a cell in showcase_status_priority_matrix (status × priority) and verify the drilled list is scoped by BOTH dimensions", + "screenshot the drill drawer each time (drill opens in-place as a drawer by default — chart.zod.ts drill target enum drawer|dialog|navigate)" + ], + "acceptance": [ + { + "clause": "the drilled list for a date bucket is scoped to exactly that time range — not a superset", + "oracle": "network", + "verify": "the drill request carries the bucket's from/to bounds (a month click carries that month's bounds, not the year's)", + "evidence": "the captured drill query" + }, + { + "clause": "the drilled row count equals the clicked bucket's aggregate", + "oracle": "api", + "verify": "count of drilled rows == the bucket count from the pre-established aggregate query, for both the status drill and the month drill", + "evidence": "count comparison table" + }, + { + "clause": "a matrix cell drill is scoped by BOTH the row and column dimensions", + "oracle": "network", + "verify": "the drill request from a status × priority cell carries both filters; row count matches that cell's value", + "evidence": "the captured query + count" + }, + { + "clause": "the drill is a strict subset when other buckets are non-empty — proving the scope is real, not cosmetic", + "oracle": "api", + "verify": "drilled count < total row count whenever the pre-established aggregate shows more than one non-empty bucket", + "evidence": "the two counts" + }, + { + "clause": "the drilled list opens in the drawer (default drill target) with the clicked category as its heading context", + "oracle": "screenshot", + "verify": "drawer screenshot shows the drilled records and a heading matching the clicked bucket's label", + "evidence": "drawer screenshot" + } + ], + "negative": [ + "a drill that opens the object's FULL unfiltered list while looking scoped (heading says the bucket, rows say everything) is a FAIL — the network trace is the authority, not the heading" + ], + "traps": [ + "seed-data-thin", + "single-datapoint" + ], + "source": [ + "#3358 §3", + "#3415", + "packages/spec/src/ui/chart.zod.ts (drill semantics: derived filter from clicked category, target enum, drawer default)", + "packages/spec/src/ui/dashboard.zod.ts (ADR-0021 dataset-bound drill derives target+filter from the dataset row)", + "examples/app-showcase/src/ui/reports/index.ts (drilldown: true on all four reports)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358; runnable once multi-bucket seeds exist", + "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" + } + ] + }, + { + "id": "dashboards.chart-type-matrix", + "title": "Every ChartTypeSchema member renders a real chart with the correct marks and series count", + "since": "v15", + "status": "active", + "revision": 3, + "priority": "P1", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the Chart Gallery dashboard (showcase_chart_gallery) — one widget per DISTINCTLY-rendered family, bound to showcase_task_metrics / showcase_project_metrics (ADR-0021)", + "a scratch draft dashboard (via PUT /api/v1/meta/dashboard/qa_single_value_gallery?mode=draft) for the four single-value synonym types the gallery deliberately does not duplicate", + "a scratch draft dashboard for the period-over-period compareTo probe (via PUT /api/v1/meta/dashboard/qa_compareto_probe?mode=draft) — NO showcase widget authors compareTo, so this is authored fresh exactly like the strict-widget probes; bind a metric/line widget to showcase_task_metrics with a time dimension (a month-bucketed created_at) and carry compareTo: { kind: 'previousPeriod' }" + ], + "knownGaps": [ + "kpi / gauge / solid-gauge / bullet have NO distinct renderer today — they render the same single value as `metric` (chart.zod.ts NOTE: 'honest single-value synonyms … gain a dial when a gauge renderer lands'; coverage.test.ts SAME_AS_METRIC). For these four the demonstrable claim is 'renders the value, not blank', NOT 'renders a dial' — a run must not tick a dial that does not exist" + ] + }, + "steps": [ + "read the enum first: packages/spec/src/ui/chart.zod.ts ChartTypeSchema currently has exactly 20 members (see variants) — if the enum has grown, this item's variants list is stale and must be revised before the run", + "establish ground truth from the data API: distinct status count, distinct priority count, and month-bucket count for showcase_task_metrics; account count for showcase_project_metrics", + "open the Chart Gallery dashboard (Showcase app → Analytics → Chart Gallery), let it settle, and screenshot the full board", + "per gallery widget (16 families: metric ×3 tiles, bar, column, horizontal-bar, line, area, combo, pie, donut, funnel, scatter, radar, treemap, sankey, table, pivot), screenshot the widget, then verify in the DOM that a real SVG (or table element for table/pivot) rendered with the mark type the family names", + "count marks against ground truth: bar/column/horizontal-bar bars == distinct statuses (or priorities), pie/donut/funnel segments == distinct statuses/priorities, line/area points == month buckets", + "for combo, verify BOTH mark types on shared axes: task_count as bars bound to the left axis and avg_progress as a line on the right axis (the widget's series[] declares exactly that)", + "for table and pivot, verify real grouped tables: Projects by Account rows == distinct accounts with 3 measure columns; Tasks by Status × Priority cross-tab dimensions match distinct status × priority", + "author the scratch draft dashboard with four widgets of types kpi, gauge, solid-gauge, bullet, each bound to dataset showcase_task_metrics, values ['task_count'], publish it, and load it", + "verify each of the four renders the same numeric value the metric tile shows — a number, never a blank tile", + "author qa_compareto_probe with a time-dimensioned widget carrying compareTo: { kind: 'previousPeriod' }, publish it, and load it — verify a comparison series/delta actually RENDERS (not the base numbers alone with the comparison silently dropped, the pre-#5011 ADR-0021 dataset-path bug)", + "capture the per-variant verdict table" + ], + "acceptance": [ + { + "clause": "each of the 20 ChartTypeSchema variants renders a real chart (SVG marks or a real table), verified per-variant — no variant may be inferred from a sibling", + "oracle": "screenshot", + "verify": "one screenshot per variant; DOM mark-check only AFTER the screenshot confirms the surface rendered", + "evidence": "per-variant screenshot set + verdict table" + }, + { + "clause": "series/mark counts match the dataset's known values — a bar chart over status draws exactly as many bars as the seed has distinct statuses", + "oracle": "api", + "verify": "for bar, column, horizontal-bar, pie, donut, funnel, radar, line, area: mark/segment/point count equals the pre-established aggregate bucket count", + "evidence": "API bucket counts vs DOM mark counts, per widget" + }, + { + "clause": "combo renders MIXED marks on dual axes — bars (left) and a line (right) in one plot, per its series[].type / series[].yAxis config", + "oracle": "screenshot", + "verify": "the combo widget shows both rect-bars and a path-line, with two y-axes", + "evidence": "combo screenshot + DOM excerpt" + }, + { + "clause": "composition/relationship families draw their DISTINCTIVE geometry, not a fallback bar: treemap draws nested rectangles, sankey draws flow links, scatter draws points", + "oracle": "screenshot", + "verify": "each of treemap/sankey/scatter is visually its own family", + "evidence": "the three screenshots" + }, + { + "clause": "the four single-value synonyms (kpi, gauge, solid-gauge, bullet) render the SAME value as metric — a number, never a blank or an error", + "oracle": "screenshot", + "verify": "scratch-dashboard tiles each show the task_count value; evidence must note these are value-only renders (no dial) per the spec's own NOTE", + "evidence": "scratch-dashboard screenshot annotated with the honest-synonym caveat" + }, + { + "clause": "pivot renders a true cross-tab: status down × priority across with cell values reconciling to the API aggregate", + "oracle": "api", + "verify": "pivot row/column headers match distinct status/priority sets; spot-check 3 cells against the aggregate query", + "evidence": "pivot screenshot + 3-cell comparison" + }, + { + "clause": "demonstrability is pinned by the coverage ratchet — the enum cannot grow past the gallery silently", + "oracle": "test", + "verify": "run examples/app-showcase/test/coverage.test.ts ('covers every distinctly-renderable ChartType'); it enumerates ChartTypeSchema minus SAME_AS_METRIC against the gallery's widgets. Note: this pins declaration coverage only — it does NOT replace the browser render checks above", + "evidence": "the test output" + }, + { + "clause": "period-over-period compareTo renders the comparison: a widget authoring compareTo: { kind: 'previousPeriod' } on the scratch draft draws a comparison series/delta beside its base measure — the ADR-0021 dataset path that used to silently DROP the string arm now carries the converged { kind, dimension? } shape (#5011)", + "oracle": "screenshot", + "verify": "the qa_compareto_probe widget shows a base value AND a previous-period comparison (series/delta), not the base alone; DOM read only after the screenshot confirms render", + "evidence": "the compareTo widget screenshot" + }, + { + "clause": "the converged compareTo contract is pinned at the parse: { kind: 'previousPeriod' | 'previousYear', dimension? } is accepted THROUGH the dashboard metadata root, while the retired spellings (the bare 'previousPeriod' string, { offset: '7d' }) are rejected with the #5011 upgrade in hand — a strict schema nobody parses would gate nothing", + "oracle": "test", + "verify": "run packages/spec/src/ui/dashboard-compareto.test.ts (#5011) — it asserts the converged shape parses via getMetadataTypeSchema('dashboard') and every retired spelling is rejected with its prescription at the top level; evidence is the test output", + "evidence": "the dashboard-compareto.test.ts run output" + } + ], + "negative": [ + "any variant rendering as a DIFFERENT family than named (e.g. sankey falling back to a bar) is a FAIL — advertising a type that renders as something else is the exact failure the trimmed enum exists to prevent", + "a blank widget with no error for any variant is a FAIL", + "a compareTo widget rendering its base numbers with the comparison silently absent (the pre-#5011 dropped-string-arm bug) is a FAIL, not a thin-data caveat" + ], + "variants": [ + "bar", + "horizontal-bar", + "column", + "line", + "area", + "pie", + "donut", + "funnel", + "scatter", + "treemap", + "sankey", + "combo", + "gauge", + "solid-gauge", + "metric", + "kpi", + "bullet", + "radar", + "table", + "pivot" + ], + "traps": [ + "single-datapoint", + "hydration-race", + "stale-console-bundle" + ], + "source": [ + "packages/spec/src/ui/chart.zod.ts (ChartTypeSchema — 20 members; NOTE on trimmed variants and single-value synonyms)", + "examples/app-showcase/src/ui/dashboards/chart-gallery.dashboard.ts", + "examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts", + "examples/app-showcase/src/coverage.ts + examples/app-showcase/test/coverage.test.ts (SAME_AS_METRIC waiver)", + "packages/spec/src/ui/dashboard-compareto.test.ts (#5011 — compareTo converged on the executor's { kind, dimension? } contract; parses through the dashboard root; retired spellings rejected with the upgrade)", + "packages/spec/src/ui/dashboard.zod.ts (DashboardWidgetSchema compareTo slot — union-free strict object so its prescription reaches the wire)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new matrix item: per-variant render proof for the full ChartTypeSchema enum, grounded in the Chart Gallery + coverage ratchet", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "added the period-over-period compareTo: { kind: 'previousPeriod' } clause + scratch-draft probe + parse pin (dashboard-compareto.test.ts #5011)", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 3, + "date": "2026-08-08", + "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly", + "ref": "claude/platform-test-checklist-ocwugl" + } + ], + "enumSource": { + "file": "packages/spec/src/ui/chart.zod.ts", + "export": "ChartTypeSchema", + "expect": 20 + } + }, + { + "id": "dashboards.dataset-report-authoring", + "title": "Dataset-only authoring works end to end: layout-less designer drafts save/publish, and every ReportType renders from the same semantic datasets", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the four seeded reports (showcase_hours_by_status, showcase_status_priority_matrix, showcase_task_overview, showcase_hours_by_status_chart) and the semantic datasets they bind (showcase_task_metrics)" + ], + "knownGaps": [ + "ReportType 'tabular' is deliberately NOT demonstrated as a report — ADR-0021 Phase 2 converted the flat TaskListReport into the `tabular` ListView on showcase_task (an object-bound row lens, ADR-0017); the coverage test filters it out. Verify the variant via that ListView, do not demand a tabular report fixture" + ] + }, + "steps": [ + "author a designer-shaped dashboard draft via PUT /api/v1/meta/dashboard/qa_designer_probe?mode=draft: columns set, widgets binding dataset + dimensions/values BY NAME and carrying NO layout (exactly what the Studio designer's addWidget writes) — e.g. a metric on task_count, a bar on status, a donut on priority", + "publish the draft and load the published dashboard in the browser", + "read the published metadata back via GET /api/v1/meta/dashboard/qa_designer_probe and inspect the stored widget shape", + "open each seeded report from the Analytics nav: Hours by Status (summary), Status × Priority (matrix), Task Overview (joined), Hours by Status (Chart)", + "for the summary report, compare each row's est_hours sum to a direct dataset/aggregate query for that status", + "for the matrix report, verify the rows × columns cross-tab (status down, priority across) and reconcile 3 cells to the aggregate", + "for the joined report, verify BOTH blocks render (open_block with runtimeFilter done:false, done_block with done:true) and that their totals partition the task population", + "for the chart report, verify the embedded DatasetReportChart plots the same measure the table shows (bar over status × est_hours, a second queryDataset call per ADR-0021)", + "verify the tabular variant via the showcase_task `tabular` ListView ('Task List' nav node)", + "attempt the negative: author a joined report whose blocks[] contains a block of type 'joined' and capture the rejection" + ], + "acceptance": [ + { + "clause": "a layout-less designer-shaped draft saves (200) and publishes — the exact shape that used to 422 and lock Publish", + "oracle": "api", + "verify": "PUT ?mode=draft returns 200 and the publish call succeeds; the pinning dogfood test may serve as evidence per rule 6", + "evidence": "the two responses (or the pinned test's output)" + }, + { + "clause": "the published dashboard persists the ADR-0021 single form — dataset + dimensions + values, no legacy inline-analytics keys", + "oracle": "api", + "verify": "GET /api/v1/meta/dashboard/qa_designer_probe body: every widget has dataset+values, none has object/categoryField/valueField/aggregate", + "evidence": "the metadata read" + }, + { + "clause": "the published layout-less widgets RENDER (auto-flowed grid), proving publish produced a usable dashboard, not just a stored row", + "oracle": "screenshot", + "verify": "the published qa_designer_probe draws all three widgets", + "evidence": "screenshot" + }, + { + "clause": "summary report cells reconcile with the semantic layer — each status row's est_hours equals the dataset aggregate for that status", + "oracle": "api", + "verify": "row-by-row comparison of the rendered summary against a direct aggregate query", + "evidence": "comparison table" + }, + { + "clause": "matrix is a true pivot (rows × columns × measure cells) whose cells reconcile to the same aggregates", + "oracle": "api", + "verify": "3 spot-checked cells of showcase_status_priority_matrix equal the aggregate query values", + "evidence": "matrix screenshot + cell comparison" + }, + { + "clause": "joined report renders both blocks and their runtimeFilters actually partition: open_block totals + done_block totals == unfiltered totals", + "oracle": "api", + "verify": "sum comparison across the two blocks vs the unfiltered aggregate", + "evidence": "the three totals" + }, + { + "clause": "the embedded report chart plots the bound dataset's measure — same numbers as the table above it", + "oracle": "screenshot", + "verify": "Hours by Status (Chart): bar heights correspond to the table's est_hours values", + "evidence": "screenshot + table values" + }, + { + "clause": "every ReportType variant is verified per-variant: summary, matrix, joined as reports; tabular via its ListView-lens home", + "oracle": "screenshot", + "verify": "one evidence artifact per variant, with tabular's captured on the showcase_task tabular ListView and annotated with the ADR-0021 Phase 2 rationale", + "evidence": "per-variant artifact set" + } + ], + "negative": [ + "a joined block nested inside blocks[] must be rejected at parse (block type enum is tabular|summary|matrix — no recursion); a silent save is a FAIL", + "a report or widget silently rendering while its dataset binding names nothing (blank-without-error) is a FAIL" + ], + "variants": [ + "tabular (via ListView lens)", + "summary", + "matrix", + "joined" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts" + }, + "traps": [ + "hydration-race", + "seed-data-thin" + ], + "source": [ + "packages/spec/src/ui/report.zod.ts (ReportType enum; block type enum excludes joined)", + "packages/spec/src/ui/dashboard.zod.ts (ADR-0021 dataset+dimensions+values single form)", + "packages/spec/src/ui/dataset.zod.ts", + "examples/app-showcase/src/ui/reports/index.ts (the four reports + Phase 2 tabular conversion note)", + "examples/app-showcase/test/coverage.test.ts ('covers every report type', tabular filtered)", + "packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new item: dataset-only authoring contract (ADR-0021) + ReportType variant matrix, pinned to the designer-roundtrip golden test", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "dashboards.empty-null-bucket-boundaries", + "title": "Empty results, NULL group buckets and single datapoints degrade honestly — designed empty states, consistent bucket labels, no one-bar collapse", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the Delivery Operations dashboard's global status filter + dateRange (created_at, last_90_days default) as the empty-window lever", + "a column with NULL group values for the NULL-bucket check (e.g. accounts without signed_on — the seed leaves prospects unset by design)" + ] + }, + "steps": [ + "run the two pinned parity suites first (rule 6 — don't re-prove what automation pins): packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts and packages/qa/dogfood/test/empty-group-bucket-parity.test.ts, capturing their output", + "open Delivery Operations and set the dateRange to a custom window BEFORE any seeded created_at (e.g. a week in 2020) so every bound widget's result set is empty", + "screenshot the board in the empty-window state and capture the browser console", + "verify via the network trace that the dataset queries carried the empty window's bounds and returned empty/zero results (the emptiness is server-truth, not a render glitch)", + "reset the window; set the global status filter to a single status and then drill toward a single-datapoint shape (one bucket) on a trend chart; screenshot", + "query a NULL-carrying group via the data API (accounts grouped on a column where prospects are NULL) and record the bucket label shape returned", + "check the trend charts against the month-bucket ground truth: the line_created widget must show one point per seeded month, never a single collapsed bucket" + ], + "acceptance": [ + { + "clause": "an empty result renders a designed empty state — never a broken axis-only plot, a JS error, or stale previous data", + "oracle": "screenshot", + "verify": "empty-window screenshot shows empty states / zeroed KPI tiles; console capture has no errors; no widget still shows the pre-filter values", + "evidence": "screenshot + console log" + }, + { + "clause": "the emptiness is authoritative: the dataset queries carried the new bounds and the server returned empty — the repaint reflects a real re-query", + "oracle": "network", + "verify": "captured queries include the 2020 window bounds; responses are empty/zero", + "evidence": "the network trace" + }, + { + "clause": "KPI metric tiles show 0 (or an explicit empty marker) for an empty window — not the last non-empty value", + "oracle": "api", + "verify": "tile values equal a direct aggregate query with the same bounds (which returns 0/empty)", + "evidence": "tile screenshot + query result" + }, + { + "clause": "date buckets are identical whether the engine pushes SQL down or falls back in-memory, for every granularity a driver advertises", + "oracle": "test", + "verify": "date-bucket-parity-conformance.test.ts passes (the #3773 seam: epoch-ms datetimes once bucketed as NULL and collapsed trend charts to one bar)", + "evidence": "test run output" + }, + { + "clause": "a NULL group value produces ONE consistent bucket label shape across both aggregation paths", + "oracle": "test", + "verify": "empty-group-bucket-parity.test.ts passes (the #3839 seam: SQL NULL vs in-memory '(null)' — totals reconciled, the label's TYPE diverged)", + "evidence": "test run output" + }, + { + "clause": "single-datapoint charts render the lone mark, and the run's evidence NOTES the weakness instead of counting it as full proof", + "oracle": "screenshot", + "verify": "the one-bucket chart draws; the evidence entry carries the single-datapoint annotation per the trap vocabulary", + "evidence": "annotated screenshot" + } + ], + "negative": [ + "a trend chart collapsing every row into one bucket while the seed spans multiple months is the #3773 regression returned — FAIL, not 'thin data'", + "an empty window that leaves widgets showing previous (stale) values with no re-query in the trace is a FAIL" + ], + "automated": { + "kind": "conformance", + "ref": "packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts" + }, + "traps": [ + "single-datapoint", + "seed-data-thin" + ], + "source": [ + "packages/qa/dogfood/test/date-bucket-parity-conformance.test.ts (#3773)", + "packages/qa/dogfood/test/empty-group-bucket-parity.test.ts (#3839)", + "examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts (dateRange + global filter levers)", + "examples/app-showcase/src/data/seed/index.ts (prospects carry no signed_on — the deliberate NULL population)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new item: boundary behavior (empty window, NULL bucket, single datapoint) pinned to the two bucket-parity conformance suites", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "dashboards.global-filters-rescope", + "title": "Dashboard-level dateRange + global filters re-scope every bound widget through its OWN field mapping; opted-out widgets stay fixed; and the Studio widget inspector AUTHORS those filterBindings", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the Revenue Pulse dashboard (showcase_revenue_pulse) — the framework#2501 acceptance fixture: one dateRange (default field issued_on) + one region filter (default field region) driving invoice AND account widgets, account widgets re-mapping via filterBindings (dateRange→signed_on, region→sales_region), one KPI opted out of both (filterBindings false) as the all-time reference", + "seeded accounts spread across sales_region amer/emea/apac with signed_on dates (src/data/seed/index.ts)" + ] + }, + "steps": [ + "open Revenue Pulse (Showcase app → Analytics → Revenue Pulse) and capture the initial dataset queries for all widgets", + "record the opted-out KPI's value and the query set", + "change the region filter to a specific region (e.g. emea) and capture every re-issued dataset query", + "verify field mapping in the trace: invoice-bound widgets filter on THEIR field (region), account-bound widgets on sales_region", + "change the dateRange to a custom window and capture the re-queries: invoice widgets carry issued_on bounds, account widgets carry signed_on bounds", + "confirm the opted-out KPI issued NO re-query and shows the unchanged all-time value", + "cross-check one re-scoped widget's displayed aggregate against a direct API aggregate with the same filter", + "repeat the global-filter check on Delivery Operations: the Task Status select must re-scope the KPI hero tiles, composing with each tile's own per-widget filter", + "screenshot before/after each filter change", + "D1 (dirty→Reset): on Revenue Pulse, confirm the filter bar shows NO Reset affordance in its pristine state; change a filter away from its defaultValue and confirm a Reset button (RotateCcw) appears; click it and confirm every filter returns to its default and the widgets re-query back", + "D3 ({value,label} options): open the region select and confirm its options render as {value,label} pairs (the trigger shows the LABEL, the committed value is the option value); capture the option-source request — a select whose options come from a dataset issues a server GROUP BY (queryDataset) for the distinct value list, not a truncated client dedupe", + "D2 (Studio authoring round-trip): in the Studio metadata-admin dashboard designer, select a widget and open the Filter Bindings section of the widget inspector; author a binding — re-target the dateRange (or region) filter to a specific field via the field-override combo, and toggle Apply OFF on another widget to opt it out; publish the draft", + "re-read the persisted metadata (GET /api/v1/meta/dashboard/) and confirm the authored widget.filterBindings shape landed; reload the dashboard and confirm the authored binding DRIVES the widget at runtime (re-targeted field in the re-query; opted-out widget no longer re-queries)" + ], + "acceptance": [ + { + "clause": "changing the region filter re-issues server queries for every bound widget, each mapped to the widget's OWN field (region vs sales_region)", + "oracle": "network", + "verify": "the trace shows re-queries with the selected region on the correct per-widget field, for both objects", + "evidence": "the network trace, per widget" + }, + { + "clause": "changing the dateRange re-scopes both objects through their own date fields (issued_on vs signed_on)", + "oracle": "network", + "verify": "re-queries carry the custom window's bounds on the mapped field per widget", + "evidence": "the network trace" + }, + { + "clause": "the opted-out KPI (filterBindings: false) neither re-queries nor changes value — the opt-out is real", + "oracle": "network", + "verify": "no query for that widget after either filter change; displayed value identical before/after", + "evidence": "trace absence + before/after screenshots" + }, + { + "clause": "displayed aggregates match server truth under the active filters", + "oracle": "api", + "verify": "one re-scoped widget's value equals a direct aggregate query with the same region+window", + "evidence": "the comparison" + }, + { + "clause": "date-scoped account charts exclude accounts with no signed_on (prospects) — absence of a date excludes the row, by design", + "oracle": "api", + "verify": "the account-side aggregate under any date window excludes NULL-signed_on accounts; count matches the API query", + "evidence": "query comparison" + }, + { + "clause": "on Delivery Operations, the global status filter composes with per-widget filters — an at-risk KPI tile under a global status selection shows the intersection, verified against the API", + "oracle": "api", + "verify": "tile value equals the aggregate with BOTH filters applied", + "evidence": "tile screenshot + query result" + }, + { + "clause": "D2 — the Studio widget inspector AUTHORS filterBindings that round-trip and drive the widget: a binding authored in the inspector's Filter Bindings section persists into widget.filterBindings and, on reload, re-scopes the widget through the authored field (or opts it out when Apply is unchecked) (objectui#2586)", + "oracle": "api", + "verify": "GET /api/v1/meta/dashboard/ after publish carries the authored filterBindings map (field override string, or false for opt-out); the reloaded widget's re-query targets the authored field / issues no query when opted out", + "evidence": "the persisted metadata + the reloaded widget's re-query trace" + }, + { + "clause": "D1 — the filter bar's Reset affordance is dirty-gated: a pristine bar (every value == its defaultValue) shows NO Reset; changing any filter surfaces a Reset (RotateCcw) that restores all filters to defaults and re-queries the widgets back", + "oracle": "dom", + "verify": "the DashboardFilterBar (data-testid dashboard-filter-bar) has no Reset in the pristine state and one once a value diverges from its default (isDirty); after Reset, values match defaults and widgets re-query — DOM read only after a screenshot confirms the bar rendered", + "evidence": "before/after screenshots + the dirty-vs-pristine DOM" + }, + { + "clause": "D3 — select/lookup filter options render as {value,label}: the trigger shows the selected option's LABEL while the committed value is the option value, and a dataset-sourced option list comes from a SERVER group-by (queryDataset), not a truncated top-N client dedupe (#2578 item 5)", + "oracle": "network", + "verify": "the option-list request is a queryDataset GROUP BY over the source object (complete regardless of row count); the rendered SelectItems carry value/label from resolveDashboardFilterDefs' normalized pairs", + "evidence": "the option-source query trace + the rendered option value/label pairs" + } + ], + "negative": [ + "a filter change that repaints without a server re-query (client-side cosmetic filtering) is a FAIL — the network trace is the oracle, not the repaint", + "the opted-out KPI drifting after a filter change is a FAIL (the fixed reference is the point of the opt-out)", + "a filterBinding authored in the inspector that does NOT persist (absent from the re-read metadata) or does NOT drive the widget on reload is a FAIL — a designer that writes into the void is worse than no designer", + "a Reset button visible on a pristine (undirtied) bar, or a select option list silently truncated to the first N rows (missing values that exist), is a FAIL" + ], + "traps": [ + "hydration-race", + "seed-data-thin" + ], + "source": [ + "examples/app-showcase/src/ui/dashboards/revenue-pulse.dashboard.ts (framework#2501 / objectui#2578 acceptance fixture)", + "examples/app-showcase/src/ui/dashboards/ops-dashboard.dashboard.ts", + "examples/app-showcase/src/data/seed/index.ts (sales_region + signed_on seeding, prospects unset by design)", + "examples/app-showcase/src/coverage.ts (dashboard kind notes: revenue-pulse demonstrates dashboard-level filters)", + "objectui packages/app-shell/src/views/metadata-admin/inspectors/DashboardWidgetInspector.tsx (Filter Bindings section — Apply toggle writes false for opt-out, field-override combo, data-testid widget-filter-binding-, patches draft.widgets[i].filterBindings; objectui#2586)", + "objectui packages/plugin-dashboard/src/DashboardFilterBar.tsx (isDirty→Reset affordance RotateCcw, data-testid dashboard-filter-bar; SelectFilter {value,label} options + server GROUP BY via queryDataset, #2578 item 5)", + "objectui packages/core/src/utils/dashboard-filters.ts (resolveDashboardFilterDefs normalizes options to {value,label} pairs)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new item: dashboard-level filter re-scoping with per-widget filterBindings, both-sides (bound vs opted-out) verification", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "added the Studio widget-inspector filterBindings AUTHORING round-trip (D2, objectui#2586) plus the D1 dirty→Reset affordance and D3 {value,label} filter-option render clauses", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "dashboards.saved-report-ownership", + "title": "Saved reports are owner-isolated: cross-owner read/run/delete deny as 404, schedules included", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "user A (report owner)", + "user B (fresh sign-up, no relation)", + "anonymous" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "two runtime-signed-up users (showcase seeds no sys_saved_report rows and only the admin — create both users and the report in-run)" + ] + }, + "steps": [ + "as A: POST /api/v1/reports (201) and POST /api/v1/reports/:id/schedule; record both ids", + "as B: GET /api/v1/reports/:idA, DELETE /api/v1/reports/:idA, POST /api/v1/reports/:idA/run — capture each", + "as B: GET /api/v1/reports (list) with and without ?ownerId=A — capture row sets", + "as B: GET /api/v1/reports/:idA/schedules and DELETE /api/v1/reports/schedules/:scheduleIdA — capture each (see the known-gap clause)", + "anonymous: GET /api/v1/reports", + "as A afterwards: re-read the report and schedule to prove nothing was destroyed" + ], + "acceptance": [ + { + "clause": "cross-owner GET/DELETE/run all answer 404 REPORT_NOT_FOUND (deny-as-404, anti-enumeration — never 403, never 2xx)", + "oracle": "api", + "verify": "the three responses' status+code per #2980's canAccessReport posture", + "evidence": "the responses" + }, + { + "clause": "the list never leaks: B's listing excludes A's report even with a forged ownerId param; ownerId cannot be spoofed on create either", + "oracle": "api", + "verify": "list bodies + a create-as-B carrying ownerId=A lands owned by B", + "evidence": "listings + the created row's owner" + }, + { + "clause": "anonymous access answers 401 UNAUTHENTICATED", + "oracle": "api", + "verify": "the anonymous GET", + "evidence": "the response" + }, + { + "clause": "KNOWN-GAP PROBE — schedule routes: unscheduleReport and listSchedules currently ignore the caller context (report-service.ts), so B deleting A's schedule succeeds today; the contract this item asserts is deny-as-404, so record the actual outcome and treat a 2xx as a FAIL with a privately-raised finding (do NOT file publicly without maintainer decision — cross-owner destructive access)", + "oracle": "api", + "verify": "the two schedule-route responses + A's schedule surviving (re-read as A)", + "evidence": "responses + the survival read" + }, + { + "clause": "A's artifacts survive every denied attempt byte-identical", + "oracle": "api", + "verify": "final re-reads as A", + "evidence": "the reads" + } + ], + "negative": [ + "any cross-owner 2xx anywhere on /api/v1/reports* is a FAIL; the schedule-route clause documents the one place a FAIL is expected TODAY — a run must not tick it green until the owner check lands" + ], + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/rest/src/rest-route-ledger.ts (reports family)", + "packages/plugins/plugin-reports/src/report-service.ts (canAccessReport #2980; the unchecked unscheduleReport/listSchedules)", + "packages/platform-objects/src/audit/sys-saved-report.object.ts", + "docs/plans/release-15.1-test-plan.md §A10 (#2980/#2981/#2975)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — 15.1 §A10 was never imported; the sweep also surfaced the unchecked schedule routes, recorded here as an expected-FAIL probe", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "dashboards.system-overview-live-counts", + "title": "The built-in System Overview dashboard renders as admin with LIVE sys_* counts, two widget values reconcile against direct API counts, single-environment-only widgets hide gracefully, and no widget renders blank", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "seeded admin / sysadmin (admin@objectos.ai)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the built-in Setup app + the platform `system_overview` dashboard (packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts) reached at Setup → System Overview (nav_system_overview, setup-nav.contributions.ts) — present in any booted app, not a showcase fixture", + "the five backing datasets over `sys_*` objects (packages/platform-objects/src/apps/dashboards/system.datasets.ts: sys_user_metrics, sys_session_metrics, sys_organization_metrics, sys_package_installation_metrics, sys_audit_log_metrics)", + "at least one signed-in session (the admin's own) so sys_session count is non-zero, and the seeded sys_user population" + ], + "knownGaps": [ + "in a SINGLE-TENANT / single-environment runtime (the stock standalone serve) two widgets are runtime-gated OFF by design: widget_organizations carries `requiresService: 'org-scoping'` and widget_packages_installed carries `requiresObject: 'sys_package_installation'` (a cloud-only object). Their ABSENCE is the graceful-hide behavior this item checks — NOT a blank tile and NOT a FAIL; a run must not demand those two render" + ] + }, + "steps": [ + "sign in as admin, open the Setup app, and navigate to System Overview (nav_system_overview → dashboard system_overview); let the board settle and screenshot the whole dashboard BEFORE reading any DOM", + "capture the dataset queries the widgets issue on load (the network trace) — each metric tile queries its sys_*_metrics dataset (count aggregate)", + "establish ground truth via the data API: GET/POST a count aggregate on sys_user, and a count aggregate on sys_session, recording both numbers", + "read the rendered value of widget_total_users (Total Users) and widget_active_sessions (Active Sessions) from the DOM (only after the screenshot confirmed render) and compare each to its API count", + "confirm widget_organizations and widget_packages_installed are ABSENT from the rendered board (runtime-gated in single-tenant), not showing a broken/empty tile", + "walk every remaining widget: the three security KPI tiles (login/permission/config event counts over sys_audit_log_metrics), the Audit Events by Action pie, the Events by User bar, and the Audit Events by Action table — confirm each draws a real mark or a named error state, none blank", + "capture the browser console for the whole load" + ], + "acceptance": [ + { + "clause": "widget_total_users shows the live user population — its value equals a direct count aggregate on sys_user, not a hardcoded or stale number", + "oracle": "api", + "verify": "the rendered Total Users tile == COUNT(sys_user) from a direct /api/v1/data aggregate, taken in the same run", + "evidence": "the tile value next to the API count" + }, + { + "clause": "widget_active_sessions shows the live session count — its value equals a direct count aggregate on sys_session (the sys_session_metrics dataset carries NO active-only filter, so the honest oracle is the full sys_session count; record the label-says-'Active' vs dataset-counts-all caveat rather than glossing it)", + "oracle": "api", + "verify": "the rendered Active Sessions tile == COUNT(sys_session) from a direct /api/v1/data aggregate", + "evidence": "the tile value + the API count + the caveat note" + }, + { + "clause": "the reconciliation is authoritative, not cosmetic: the widget's OWN dataset query (on the wire) returns the same count the direct /data aggregate returns — the tile reflects a real query, not a placeholder", + "oracle": "network", + "verify": "the captured sys_user_metrics / sys_session_metrics dataset query results match both the tile and the direct aggregate", + "evidence": "the two dataset-query traces" + }, + { + "clause": "single-environment-only widgets hide gracefully: in a single-tenant runtime widget_organizations (requiresService: org-scoping) and widget_packages_installed (requiresObject: sys_package_installation) are ABSENT, not rendered as blank/error tiles", + "oracle": "screenshot", + "verify": "the board screenshot shows neither gated widget occupying a slot; the runtime-gate source (requiresService/requiresObject on the two widgets) explains the absence", + "evidence": "the board screenshot annotated with the two gated widget ids" + }, + { + "clause": "no widget renders blank: every PRESENT widget draws its mark — metric tiles show numbers, the Audit Events by Action pie shows segments, the Events by User bar shows bars, the table shows rows — or shows a named error state", + "oracle": "screenshot", + "verify": "per-widget screenshot check; DOM mark-count only after the screenshot confirms render (hydration-race guard)", + "evidence": "the per-widget render verdicts" + }, + { + "clause": "the distribution charts reflect the real audit population, not one lonely datapoint: the Audit Events by Action pie/table segment count equals the distinct sys_audit_log.action values present", + "oracle": "api", + "verify": "segment/row count == distinct action count from a direct sys_audit_log group-by aggregate; if only one action is seeded, annotate the single-datapoint weakness rather than counting it as full proof", + "evidence": "the distinct-action aggregate vs the rendered segments" + } + ], + "negative": [ + "a blank widget with no error for any PRESENT widget is a FAIL — the exact blank-tile failure this item exists to catch", + "a runtime-gated widget (organizations / packages) rendering a broken or empty tile INSTEAD of hiding is a FAIL — the gate must remove it, not render it empty", + "a KPI tile whose value does not match the direct API count (a stale cache or a hardcoded number) is a FAIL — the tick 'says what it looks like it says' only if the numbers reconcile", + "running this as a non-admin persona proves nothing about the sysadmin surface (wrong-persona) — and Setup itself must refuse the member" + ], + "traps": [ + "hydration-race", + "single-datapoint", + "seed-data-thin", + "wrong-persona" + ], + "source": [ + "packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts (widget ids, requiresService/requiresObject gates, globalFilters date range)", + "packages/platform-objects/src/apps/dashboards/system.datasets.ts (the five sys_*_metrics datasets and their count measures)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_system_overview → dashboard system_overview in the Setup app)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new item: built-in System Overview live-count reconciliation + graceful single-environment widget hiding, grounded in the platform dashboard + sys_*_metrics datasets", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "dashboards.cube-query", + "title": "The showcase_delivery analytics cube serves /api/v1/analytics/*: meta discovers its measures/dimensions, a query answers a known aggregate that reconciles against a direct /data aggregate, and an unwired analytics slot degrades honestly to 404", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase_delivery cube (examples/app-showcase/src/data/analytics/showcase.cube.ts — verified present: base table showcase_task; measures count / total_estimate_hours / avg_estimate_hours / done_rate; dimensions status / priority / due_date / assignee), registered as `analyticsCubes` (examples/app-showcase/src/coverage.ts) and served by the `analytics` capability the CLI serve path auto-loads (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)", + "seeded showcase_task rows spanning multiple statuses (the same multi-bucket seed dashboards.chart-first-paint relies on) so the reconciled aggregate has more than one bucket" + ] + }, + "steps": [ + "probe the slot first: GET /api/v1/analytics/meta and record the status — a 404 (handled:false) means the analytics service slot is empty or filled by a self-declared stub; go straight to the degradation clause and record the honest absence", + "GET /api/v1/analytics/meta?cube=showcase_delivery and record the returned cube descriptor", + "establish ground truth: POST /api/v1/data/showcase_task/query with a status group-by count (and a SUM of estimate_hours per status), recording each bucket's count and hour total", + "POST /api/v1/analytics/query { cube: 'showcase_delivery', measures: ['showcase_delivery.count'], dimensions: ['showcase_delivery.status'] } and record rows", + "POST /api/v1/analytics/query { cube: 'showcase_delivery', measures: ['showcase_delivery.total_estimate_hours'], dimensions: ['showcase_delivery.status'] } and record rows", + "reconcile the cube rows against the direct /data aggregate bucket-for-bucket (count and hours)", + "negative: POST /api/v1/analytics/query { cube: 'showcase_delivery', filters: { status: 'done' } } (the off-contract `filters` key instead of `where`) and capture the refusal; also POST the retired { cube, query: {...} } envelope and capture its rejection" + ], + "acceptance": [ + { + "clause": "meta discovers the cube: GET /analytics/meta lists showcase_delivery with exactly its four measures (namespaced showcase_delivery.count / .total_estimate_hours / .avg_estimate_hours / .done_rate) and four dimensions (showcase_delivery.status / .priority / .due_date / .assignee)", + "oracle": "api", + "verify": "the meta response's cubes[] entry for showcase_delivery names all four measures and four dimensions (getMeta keys them `${cube}.${key}` and does NOT filter on the cube's public:false flag)", + "evidence": "the /analytics/meta?cube=showcase_delivery body" + }, + { + "clause": "a known aggregate reconciles: count grouped by status from the cube equals the direct /data showcase_task count-by-status aggregate, bucket-for-bucket", + "oracle": "api", + "verify": "row-by-row equality of showcase_delivery.count-by-status against the /api/v1/data/showcase_task group-by count", + "evidence": "the two aggregates side by side" + }, + { + "clause": "a numeric measure reconciles too, proving the semantic layer is not just counting rows: total_estimate_hours by status equals SUM(estimate_hours) grouped by status from /data", + "oracle": "api", + "verify": "row-by-row equality of showcase_delivery.total_estimate_hours-by-status against the direct SUM aggregate — a divergence would expose a fan-out double-count through the showcase_project join", + "evidence": "the two hour aggregates" + }, + { + "clause": "the query rides the caller's read scope: the /analytics domain forwards the request ExecutionContext (analytics.ts #2852), so results are RLS/tenant-scoped — not run UNSCOPED — verified structurally here (admin reconciles against the same-scoped /data aggregate; the cross-persona proof lives in search.rls-both-personas / access-security)", + "oracle": "api", + "verify": "the admin cube aggregate == the admin /data aggregate; cite handleAnalyticsRequest passing context.executionContext as the mechanism", + "evidence": "the matched aggregates + the mechanism citation" + }, + { + "clause": "DEGRADATION: an empty analytics slot (or a self-declared stub) answers 404 handled:false on BOTH /analytics/query and /analytics/meta — the honest 'install service-analytics' signal (#3891/#4000) — never a 500 and never a silent 200-empty masquerading as an answer", + "oracle": "api", + "verify": "on stock showcase the slot is filled (verify by the served meta above and record this clause as served-not-absent); to exercise the 404 arm, drive an environment without the analytics capability and confirm the 404 rather than a 500/empty-200", + "evidence": "the served-meta status, plus the 404 body if the absent-slot arm is exercised" + }, + { + "clause": "a malformed AnalyticsQuery is rejected AT THE ENTRY with a 400 that names what is wrong — `filters` is told to use `where`, and the retired { query } / { format } envelope carries its migration hint — never forwarded to the engine to die as a `SELECT FROM` SQL error", + "oracle": "api", + "verify": "the `filters` body answers 400 VALIDATION_FAILED naming `where` (analytics.ts assertAnalyticsQueryBody); the { query } envelope is rejected via the retiredKey tombstone (analytics.zod.ts AnalyticsQueryRequestSchema.strict())", + "evidence": "the two refusal bodies" + } + ], + "negative": [ + "cube rows that do NOT reconcile with the direct /data aggregate (a join fan-out double-count, or a wrong grouping) are a FAIL — the semantic layer must agree with the base-table truth", + "a 200 empty-success where the analytics slot is actually absent (instead of the honest 404) is a FAIL — silent degradation is exactly what #3891/#4000 replaced", + "a malformed body reaching the engine and dying as a 500 SQL error instead of a 400 at the entry is a FAIL", + "the route existing only in unit tests but 404-ing on the live server is the dispatcher-vs-hono-route class — the oracle is a live HTTP trace, never a simulated dispatch" + ], + "variants": [ + "measure count (type: count)", + "measure total_estimate_hours (type: sum)", + "measure avg_estimate_hours (type: avg)", + "measure done_rate (type: number, computed CASE expression)" + ], + "traps": [ + "dispatcher-vs-hono-route", + "seed-data-thin", + "single-datapoint" + ], + "source": [ + "examples/app-showcase/src/data/analytics/showcase.cube.ts (the showcase_delivery cube — measures, dimensions, base table, showcase_project join, public:false)", + "examples/app-showcase/src/coverage.ts (analyticsCubes registration → src/data/analytics/showcase.cube.ts, served by /api/v1/analytics/*)", + "packages/runtime/src/domains/analytics.ts (route contract: POST /analytics/query, GET /analytics/meta[?cube], entry validation, ExecutionContext scoping #2852, handled:false 404 for an absent/stub slot #3891/#4000)", + "packages/spec/src/api/analytics.zod.ts (AnalyticsQueryRequestSchema bare shape + retiredKey query/format; meta response cubes[])", + "packages/services/service-analytics/src/analytics-service.ts (getMeta keys measures/dimensions as `${cube}.${key}`, returns all registry cubes)", + "packages/cli/src/commands/serve.ts (CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new item: showcase_delivery cube /analytics/* meta+query reconciliation against the direct /data aggregate, with the honest empty-slot 404 degradation clause and the entry-validation negatives", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/i18n.json b/docs/qa/platform-checklist/areas/i18n.json new file mode 100644 index 0000000000..d3607078ec --- /dev/null +++ b/docs/qa/platform-checklist/areas/i18n.json @@ -0,0 +1,443 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "i18n", + "title": "Internationalization", + "items": [ + { + "id": "i18n.notification-localized-and-clears", + "title": "zh-CN notification is localized, deep-links localized, and mark-as-read clears", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": ["zh-CN workspace user receiving a collab.assignment notification", "en workspace user (control for per-recipient localization)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase config declares i18n.supportedLocales ['en', 'zh-CN'] (examples/app-showcase/objectstack.config.ts) — the zh-CN session is stock, no extra fixture", + "a second user to assign work to, so the assignment notification has a recipient distinct from the actor" + ] + }, + "steps": [ + "boot the showcase under os dev/standalone, sign in as admin, and set the recipient user's locale to zh-CN", + "trigger a collab.assignment notification by assigning a seeded showcase_task to the zh-CN recipient (edit the task's assignee field)", + "sign in as the recipient (own browser context — never a shared tab), open the bell, and screenshot the notification entry", + "click through the notification's deep link and screenshot the target detail page", + "capture the mark-as-read request (POST /api/v1/notifications/read) and the unread-count reads before and after", + "reload the page fully and re-read the unread count from the server", + "repeat the trigger for an en-locale recipient and capture that bell entry as the control" + ], + "acceptance": [ + { + "clause": "the bell entry's title renders in the recipient locale (zh-CN)", + "oracle": "screenshot", + "verify": "bell screenshot shows the assignment title in zh-CN, no raw message key", + "evidence": "bell screenshot" + }, + { + "clause": "the deep-link target renders in zh-CN — the click-through lands on a localized page, not a mixed-language one", + "oracle": "screenshot", + "verify": "detail-page screenshot in zh-CN (translated object label, field labels, section labels)", + "evidence": "detail screenshot" + }, + { + "clause": "mark-as-read actually clears the unread state — the notifications REST routes must be mounted on the server actually serving os dev (the #3362 dispatcher-only registration made the console 404 here while unit tests stayed green)", + "oracle": "network", + "verify": "POST /api/v1/notifications/read returns 2xx on the running server and a fresh unread-count read drops", + "evidence": "the network trace + before/after unread count" + }, + { + "clause": "the cleared state is authoritative — it survives a full reload, proving the server persisted it rather than the client repainting", + "oracle": "api", + "verify": "after a hard reload, the server's unread count still reflects the read (no resurrection of the cleared item)", + "evidence": "post-reload unread-count read" + }, + { + "clause": "localization is per-recipient, not session-global: the same event notifies the en control user in en", + "oracle": "screenshot", + "verify": "the en recipient's bell entry for an equivalent assignment renders in English", + "evidence": "control-user bell screenshot" + } + ], + "negative": [ + "a mark-as-read that returns 404/405 (routes mounted only on the dispatcher, not the live Hono server) is the #3362 regression — FAIL, and unit-test greenness is not a defense" + ], + "traps": ["dispatcher-vs-hono-route", "shared-browser-tab"], + "source": ["#3358 §7", "#3362", "#3354", "examples/app-showcase/objectstack.config.ts (supportedLocales)"], + "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" } + ] + }, + { + "id": "i18n.studio-follows-app-locale", + "title": "Studio follows the in-app locale switch — no mixed-language session", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "the platform-owned zh-CN metadata-form translations (the metadataForms translation group is owned and translated by platform-objects — scripts/check-i18n-coverage.mjs folds that baseline away because it is platform-owned)" + ] + }, + "steps": [ + "sign in as admin on the showcase, switch the in-app locale to zh-CN, and confirm the app shell followed (nav in zh-CN)", + "navigate into Studio / metadata-admin: open the object editor for showcase_project and the dashboard designer for showcase_chart_gallery", + "screenshot the object editor's form — its labels/sections come from the metadataForms translation group (packages/spec/src/system/translation.zod.ts: metadataForms..label / sections / fields)", + "screenshot a metadata list surface showing relative dates ('x 天前'-style) in the switched locale", + "reload the browser fully and re-screenshot one Studio surface — the locale choice must survive the reload", + "switch back to en and re-screenshot the same two Studio surfaces" + ], + "acceptance": [ + { + "clause": "Studio's metadata-editor forms render in the switched locale via the metadataForms translation group", + "oracle": "screenshot", + "verify": "object-editor screenshot shows zh-CN section and field labels (e.g. 基础信息-style section labels per the schema's own example), not English", + "evidence": "object-editor screenshot" + }, + { + "clause": "no mixed-language session: a single Studio screenshot contains no untranslated declared string sitting next to translated ones", + "oracle": "screenshot", + "verify": "review each captured Studio screenshot for locale consistency of declared (translatable) strings; user data (record values) is exempt", + "evidence": "the annotated screenshots" + }, + { + "clause": "relative dates follow the locale", + "oracle": "screenshot", + "verify": "list/timeline timestamps render zh-CN relative forms after the switch", + "evidence": "screenshot" + }, + { + "clause": "the locale choice persists across a full reload — it is stored, not a transient client state", + "oracle": "screenshot", + "verify": "post-reload Studio screenshot is still zh-CN without re-selecting", + "evidence": "post-reload screenshot" + }, + { + "clause": "switching back to en restores English on the same surfaces — the switch is symmetric, not a one-way ratchet", + "oracle": "screenshot", + "verify": "the return-to-en screenshots show the same surfaces fully in English", + "evidence": "return-trip screenshots" + } + ], + "negative": [ + "a Studio surface staying English after the app switched (mixed session) is the FAIL this item exists for — check against a fresh objectui build before filing (the vendored /_console bundle may be stale)" + ], + "traps": ["stale-console-bundle"], + "source": [ + "#3358 §7", + "packages/spec/src/system/translation.zod.ts (metadataForms group + resolveMetadataFormLabels convention)", + "scripts/check-i18n-coverage.mjs (platform metadata-form baseline is platform-objects-owned)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358", "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" } + ] + }, + { + "id": "i18n.surface-matrix", + "title": "Every translatable surface localizes on a zh-CN session — one pass over the full translation-group vocabulary, plus the runtime i18n routes it resolves through", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": ["admin on a zh-CN session"], + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase translation bundle (examples/app-showcase/src/system/translations/index.ts) — it deliberately covers EVERY field surfaced as a list column so a zh-CN list never mixes locales", + "platform-objects' own bundles for sys_ object labels and Studio metadataForms (platform-owned per scripts/check-i18n-coverage.mjs)" + ], + "knownGaps": [ + "the showcase's declared surface carries FROZEN untranslated debt, ratcheted by scripts/check-i18n-coverage.mjs + i18n-coverage-baseline.json — expect some declared strings to legitimately fall back to en; the per-surface check is 'translated keys render translated, untranslated keys render the SOURCE label (never a raw dotted key)', not 100% coverage" + ] + }, + "steps": [ + "before the browser: read the served translation metadata (GET /api/v1/meta/translation, or the bundle source) and note, per variant surface, at least one key that IS translated to zh-CN — expectations derive from data, not vibes", + "sign in as admin, switch to zh-CN, and walk one surface per variant, screenshotting each:", + "nav labels — the Showcase app sidebar groups/nodes (apps..navigation..label group)", + "list headers — the showcase_task list columns (objects..fields..label; the bundle translates every surfaced column incl. status/priority OPTION labels)", + "form labels + placeholders on a task edit form (fields group), and select options in the form and list filters (fields..options)", + "an action dialog — trigger an object action with confirmText/successMessage and capture the confirm prompt and the success toast (_actions group: label/confirmText/successMessage)", + "a view empty state — open a view/slice with zero rows and capture emptyState.title/message (_views..emptyState)", + "detail-page section labels (_sections group)", + "a dashboard — Chart Gallery's title and widget titles (dashboards..label / widgets..title)", + "a page — a seeded page's header title/subtitle (pages. group)", + "a Settings namespace — title, group titles, key labels, and the source badges (settings. + settingsCommon.sourceLabels)", + "Studio metadata forms (metadataForms group — overlaps i18n.studio-follows-app-locale; a pinned pass there may be cited)", + "a sys_ object surface — e.g. the All Users list showing sys_user's translated label (platform-objects-owned bundle)", + "runtime i18n routes (server truth, consulted independently of the browser — these are what the client resolves labels through): GET /api/v1/i18n/locales and record data.locales", + "GET /api/v1/i18n/translations/zh-CN — record whether it is non-empty and whether every key path sits under a declared translation group", + "GET /api/v1/i18n/labels/showcase_task/zh-CN — record its { object, locale, labels } and hold it next to the zh-CN list-header the browser rendered for showcase_task", + "unknown-locale probe: GET /api/v1/i18n/translations/zz-ZZ and GET /api/v1/i18n/labels/showcase_task/zz-ZZ — capture WHICH degradation the running server does (the FileI18nAdapter returns an empty {} map for an unloaded locale; getTranslations does NOT synthesize the default bundle — record the observed shape, do not assume a fallback)", + "feed/audit verb variant (E11): trigger an activity-feed / audit entry (e.g. an assignment or a record edit) and, on the zh-CN session, screenshot its rendered verb/template — the feed/audit verb copy is a translatable surface", + "record a per-variant verdict table with the screenshot evidence" + ], + "acceptance": [ + { + "clause": "every variant surface renders its known-translated key in zh-CN — verified per-variant, no surface inferred from a sibling", + "oracle": "screenshot", + "verify": "one screenshot per variant showing the pre-identified translated key rendered in zh-CN", + "evidence": "per-variant screenshot set + verdict table" + }, + { + "clause": "expectations are grounded in server truth first: the translation metadata read names which keys are translated, and the screenshots are judged against THAT list", + "oracle": "api", + "verify": "the translation read (or bundle source) is captured and each per-variant expectation cites its key", + "evidence": "the translation read + key map" + }, + { + "clause": "fallback is honest: an untranslated declared string renders its SOURCE-language label — never a raw dotted key path, never an empty cell", + "oracle": "screenshot", + "verify": "spot-check at least two known-untranslated keys (from the coverage baseline debt); each shows the en label, no 'objects.x.fields.y.label' literals anywhere in the session", + "evidence": "the fallback screenshots" + }, + { + "clause": "select OPTION labels localize everywhere they appear: list cells, form selects, and list filters all show the translated option label for the same stored value", + "oracle": "screenshot", + "verify": "the showcase_task status/priority options (translated in the bundle) render zh-CN in all three places", + "evidence": "the three screenshots" + }, + { + "clause": "a list of a fully-covered object never mixes locales — the bundle's own contract (it translates every surfaced column precisely to prevent 状态 next to 'Assignee')", + "oracle": "screenshot", + "verify": "the showcase_task list header row is 100% zh-CN", + "evidence": "list screenshot" + }, + { + "clause": "action dialog copy (confirm prompt, success toast) comes from the _actions translation, and the result renders after the action actually executed", + "oracle": "network", + "verify": "the action's request fired and returned 2xx while the zh-CN confirm/success strings were shown — the toast is attached to a real server round-trip", + "evidence": "network trace + dialog/toast screenshots" + }, + { + "clause": "GET /i18n/locales lists exactly the configured locale set — data.locales carries the descriptors for the supportedLocales (showcase: en + zh-CN), inside the declared { success, data } envelope (#3636), never a bare array and never a superset", + "oracle": "api", + "verify": "the response body's data.locales equals toLocaleDescriptors(getLocales(), defaultLocale) for the configured locales; unwrapResponse keys on the success flag, so a flag-less body is itself the failure #3636 closed", + "evidence": "the /i18n/locales response" + }, + { + "clause": "GET /i18n/translations/:locale returns a non-empty bundle whose key paths all sit within the declared translation-group vocabulary (translationDataShape) — a key outside the declared groups, or a raw dotted key handed to a consumer, is the silent-strip class", + "oracle": "api", + "verify": "translations for zh-CN is non-empty; spot-check that sampled keys resolve under objects/_views/_actions/_sections/apps.navigation/dashboards/pages/settings groups, not a fourth dialect", + "evidence": "the /translations/zh-CN response + the key check" + }, + { + "clause": "the /labels/:object/:locale route is the resolver the UI reads through, and it agrees with the UI: the labels it returns for showcase_task in zh-CN match the zh-CN list-header labels the browser renders (resolveObjectFieldLabels over the nested objects..fields..label shape — the flat o. dialect that always returned {} was #3778/#3833)", + "oracle": "api", + "verify": "the labels response's field→label map equals the rendered zh-CN list-header row for showcase_task", + "evidence": "the /labels response next to the list-header screenshot" + }, + { + "clause": "an unknown locale degrades honestly and the run records WHICH: /translations/zz-ZZ and /labels/showcase_task/zz-ZZ answer 200 with an empty {} map (the adapter does not synthesize the default bundle at the route), never a 500 and never a dotted-key dump — the observed shape is captured, not assumed", + "oracle": "api", + "verify": "both unknown-locale reads return 200 with an empty translations/labels object (FileI18nAdapter's unloaded-locale path — getTranslations returns {}; fallbackLocale only applies per-KEY inside t(), not to the bulk route); record the exact bodies", + "evidence": "the two unknown-locale responses" + }, + { + "clause": "the activity-feed / audit verb template localizes on the zh-CN session (E11) — the feed/audit entry's verb renders its zh-CN copy, not an English verb and not a raw key", + "oracle": "screenshot", + "verify": "a feed/audit entry's verb/template renders zh-CN on the switched session; a raw key or English verb here is the E11 gap (record which group backs it — the messages/globalActions area is the likely home)", + "evidence": "the feed/audit screenshot" + } + ], + "negative": [ + "a raw translation key (dotted path) rendered anywhere is a FAIL — that is the silent-strip failure surfacing to the user", + "a surface whose group the spec declares (e.g. _sections) showing NO translation while the bundle carries one for it is a FAIL against the resolver, not a coverage gap", + "a /i18n/locales that answers a bare array (the pre-#3636 shape) instead of { success, data: { locales } } is a FAIL — the SDK's unwrapResponse then hands callers the wrong shape depending on which surface mounted the route", + "the /labels route and the rendered UI labels disagreeing for the same object+locale is a FAIL against the resolver — they must be one derivation (#3833), not two copies that drift" + ], + "variants": [ + "nav labels (apps.navigation)", + "list headers (objects.fields.label)", + "select option labels (objects.fields.options)", + "form labels/placeholders (objects.fields)", + "action dialogs (objects._actions: label/confirmText/successMessage)", + "view empty states (objects._views.emptyState)", + "detail section labels (objects._sections)", + "dashboards (dashboards.label / widgets.title)", + "pages (pages.label/title/subtitle)", + "settings (settings. + settingsCommon.sourceLabels)", + "Studio metadata forms (metadataForms)", + "sys_ object labels (platform-objects bundles)", + "relative dates (locale formatting, not a translation group)", + "activity-feed / audit verb templates (E11 — a translatable surface not previously in the variant list)" + ], + "traps": ["stale-console-bundle", "hydration-race", "wrong-panel", "dispatcher-vs-hono-route"], + "source": [ + "packages/spec/src/system/translation.zod.ts (translationDataShape — the authoritative group vocabulary: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, pages, settings, metadataForms, settingsCommon)", + "examples/app-showcase/src/system/translations/index.ts (full-column coverage rationale)", + "packages/services/service-i18n/src/i18n-service-plugin.ts (GET /i18n/locales | /translations/:locale | /labels/:object/:locale; { success, data } envelope #3636/#3675; resolveObjectFieldLabels nested shape #3778/#3833; the plugin mount and the dispatcher /i18n domain serve the same routes interchangeably)", + "packages/services/service-i18n/src/file-i18n-adapter.ts (getLocales / getTranslations — unloaded locale → {}; fallbackLocale applies per-KEY in t(), not to the bulk route)", + "packages/services/service-i18n/src/i18n-route-ledger.ts (the three routes, conformance-guarded #3636)", + "content/docs/ui/translations.mdx", + "scripts/check-i18n-coverage.mjs + scripts/i18n-coverage-baseline.json (frozen-debt ratchet)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new matrix item: per-surface localization pass over the spec's full translation-group vocabulary, grounded in the showcase bundle and the coverage ratchet", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-08", "change": "clause-extension: runtime i18n routes (/i18n/locales configured set, /translations/:locale ⊂ declared vocabulary, /labels/:object/:locale matches the UI, unknown-locale honest degradation), plus the E11 activity-feed/audit verb-localization variant; traps gain dispatcher-vs-hono-route (the plugin mount and dispatcher /i18n domain must answer one shape, #3636/#3833)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "i18n.strict-translation-key-rejection", + "title": "Unknown, legacy-dialect, and retired translation keys are rejected loudly at BOTH authoring doors — never silently stripped", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "runtime metadata authoring enabled (the `translation` item door: the Studio translation editor or the metadata API against /api/v1/meta/translation)" + ] + }, + "steps": [ + "item door — via the metadata API, attempt to save a zh-CN `translation` item carrying each variant in turn (one save per variant), always alongside one VALID key (e.g. objects.showcase_task.label) so the rejection provably targets the bad key, not the document", + "capture the exact error text for each attempted save", + "after each rejection, read the item back and record that nothing (including the valid key) was persisted by the failed save", + "bundle door — in a scratch script inside the worktree, call defineTranslation / defineTranslationBundle from @objectstack/spec with the same variants and capture each parse error (this is the door #3778's item-only guard missed; #4001's strict shapes must cover both)", + "verify the alias suggestion channel: a near-miss key (helpText on a FIELD translation, label on a WIDGET translation) must be rejected WITH the did-you-mean pointing at the declared spelling (help; title)", + "verify the guidance channel: legacy object-first keys (o, app, nav, dashboard, errors, _meta) and retired validationMessages must carry their prescription (where the content belongs now, or that it has no home), not just 'unrecognized key'", + "save a correct version of the same content and confirm it persists and resolves (the gate rejects keys, not translations)" + ], + "acceptance": [ + { + "clause": "every variant is REJECTED at the item door with an error naming the surface and echoing the offending key", + "oracle": "api", + "verify": "each save returns a validation error whose text contains the offending key and the surface name ('this translation', 'this field translation', …)", + "evidence": "per-variant error texts" + }, + { + "clause": "the same variants are rejected at the bundle door — the two doors cannot diverge (the #3778/#4522 asymmetry: covered at one door, open at the other)", + "oracle": "test", + "verify": "defineTranslation/defineTranslationBundle throw for every variant with equivalent error content", + "evidence": "the scratch-script output" + }, + { + "clause": "near-miss keys get a did-you-mean naming the declared spelling (aliases channel), so the author's next action is a rename", + "oracle": "log", + "verify": "helpText→help (field), label→title (dashboard widget) suggestions appear in the respective errors", + "evidence": "the two error texts" + }, + { + "clause": "legacy-dialect and retired keys carry their PRESCRIPTION (guidance channel): where the content now belongs, or an explicit 'no replacement' (errors/validationMessages: author the message on object.validations[].message)", + "oracle": "log", + "verify": "the o/app/nav/dashboard errors point at objects./apps./navigation/dashboards (plural); validationMessages cites #4667/ADR-0049 and the rule-message home", + "evidence": "the error texts" + }, + { + "clause": "a failed save persists NOTHING — the valid sibling key must not have been half-saved", + "oracle": "api", + "verify": "reading the translation item after each rejected save shows the pre-attempt state", + "evidence": "the read-back responses" + }, + { + "clause": "the corrected document saves and RESOLVES — the translated label actually renders on a zh-CN session afterwards", + "oracle": "screenshot", + "verify": "the key rejected-then-corrected (objects.showcase_task.label or similar) shows its zh-CN value in the UI", + "evidence": "post-fix screenshot" + } + ], + "negative": [ + "a 2xx save of ANY variant is a FAIL even if nothing renders wrong afterwards — silent stripping is indistinguishable from 'not translated yet' forever, which is the exact failure #4001 closed", + "a rejection that names only 'unknown key' with no surface, no echo, and no suggestion is a PARTIAL — the diagnostic contract is part of the item" + ], + "variants": [ + "legacy object-first: o", + "legacy object-first: app", + "legacy object-first: nav", + "legacy object-first: dashboard", + "legacy with no replacement: errors", + "retired: validationMessages (#4667)", + "legacy: _meta", + "alias near-miss: helpText on a field translation (→ help)", + "alias near-miss: label on a dashboard widget translation (→ title)", + "hallucinated group: a wholly invented top-level key" + ], + "traps": ["dispatcher-vs-hono-route"], + "source": [ + "packages/spec/src/system/translation.zod.ts (TRANSLATION_HISTORY, LEGACY_OBJECT_FIRST_KEYS, TRANSLATION_KEY_GUIDANCE, strict shapes at both doors — #4001, #3778, #4667)", + "packages/spec/src/shared/strict-object.ts (surface/aliases/guidance rejection contract)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: strict-key rejection at both translation doors, variants from the spec's own legacy/guidance/alias tables", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "i18n.build-gates-hold", + "title": "The i18n build gates hold and can go red: bundle drift, undeclared extract keys, and the coverage ratchet", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "build", + "personas": ["ci"], + "fixtures": { + "app": "any", + "requires": [ + "a BUILT workspace — both gates run the built CLI and now hard-fail with a named prerequisite verdict when it is missing (#5217/#5862) instead of blaming an innocent config" + ] + }, + "steps": [ + "build the workspace, then run node scripts/check-i18n-bundles.mjs and capture the output (two distinct verdicts: bundle drift; undeclared extract key)", + "run node scripts/check-i18n-bundles.mjs --self-test and node scripts/check-i18n-coverage.mjs --self-test — the classifiers must be provably able to go red (#4690: a gate observed only green is indistinguishable from a gate matching nothing)", + "run node scripts/check-i18n-coverage.mjs against the baseline", + "prove the drift gate bites: perturb one committed bundle value in the worktree, re-run check-i18n-bundles.mjs, capture the red verdict, then revert the perturbation", + "prove the ratchet bites: the baseline check must fail on an INCREASED untranslated count — verify by inspecting that the current counts equal the baseline (growth would fail), and capture the baseline comparison output", + "in an UNBUILT state (or by consulting the gates' own prerequisite check), confirm the failure mode is the named build-prerequisite verdict, not nine phantom bundle problems" + ], + "acceptance": [ + { + "clause": "check-i18n-bundles passes on a clean tree, reporting its two verdict classes separately", + "oracle": "build", + "verify": "exit 0 with the drift and undeclared-key sections both clean", + "evidence": "gate output" + }, + { + "clause": "both gates' --self-test proves the red path exists", + "oracle": "build", + "verify": "each --self-test run exercises its classifiers against fixed samples and passes", + "evidence": "self-test outputs" + }, + { + "clause": "the drift gate actually bites: a perturbed bundle turns the gate red naming the drifted package", + "oracle": "build", + "verify": "the perturbation run exits non-zero citing the perturbed bundle; the revert run is green again", + "evidence": "the red output + the post-revert green output" + }, + { + "clause": "the coverage ratchet holds the frozen debt: counts match the committed baseline, and the gate's contract fails growth", + "oracle": "build", + "verify": "check-i18n-coverage exits 0 with counts equal to scripts/i18n-coverage-baseline.json", + "evidence": "gate output + baseline diff" + }, + { + "clause": "an unbuilt workspace produces the HARD named prerequisite failure ('measured nothing'), never a skip and never phantom per-config errors", + "oracle": "build", + "verify": "the prerequisite verdict names the missing build, not an i18n config", + "evidence": "the captured failure output" + } + ], + "negative": [ + "a green run after the deliberate bundle perturbation is a FAIL of the gate itself — file against the gate, and do not trust any of its other verdicts that sweep", + "--write printing 'regenerated' while writing nothing (the historical unbuilt-CLI shape) is a FAIL" + ], + "source": [ + "scripts/check-i18n-bundles.mjs (#4804 undeclared-key verdict, #5217 prerequisite check, #4690 self-test rationale)", + "scripts/check-i18n-coverage.mjs (#3370 declared-surface ratchet, #5862 prerequisite check)", + "scripts/i18n-coverage-baseline.json" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: the i18n build lane — drift gate, undeclared-key gate, ratchet, and their provable red paths", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json new file mode 100644 index 0000000000..1330ec344b --- /dev/null +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -0,0 +1,1203 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "identity-auth", + "title": "Auth, login, identity", + "items": [ + { + "id": "identity-auth.sso-enforced-first-paint", + "title": "ssoEnforced login honors SSO on first paint — no password-wall flash", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": ["anonymous visitor at the login page", "env owner (break-glass password holder)"], + "fixtures": { + "app": "any", + "requires": [ + "an env with ssoEnforced configured, cold-started", + "ssoEnforced needs an IdP behind it: set auth.ssoOnlyMode: true (or OS_AUTH_SSO_ONLY=true) plus at least one configured oidcProviders[] entry — stock showcase ships neither, so this is a bespoke-env item" + ] + }, + "steps": [ + "configure ssoOnlyMode (or OS_AUTH_SSO_ONLY=true) with one OIDC provider entry and cold-start the server", + "BEFORE opening a browser, GET /api/v1/auth/config and record features.ssoEnforced, features.sso, and the socialProviders/oidc provider list — this is the flag the client will honor", + "open the login page in a fresh browser context and capture the FIRST paint (screenshot as early as render settles)", + "inspect the first paint for: the SSO sign-in surface, the ABSENCE of the email/password form and self-registration, and the PRESENCE of the break-glass 'use a password' link (the spec keeps the break-glass password endpoint enabled — the env owner retains an escape hatch)", + "simulate a hung sign-in (e.g. an IdP that never redirects back) and wait out the watchdog window; screenshot the recovery state", + "restart the server WITHOUT enforcement (ssoOnlyMode off) and capture the login first paint again as the other side of the gate" + ], + "acceptance": [ + { + "clause": "the server advertises the enforcement: /api/v1/auth/config features.ssoEnforced is true (server truth for what the client honors)", + "oracle": "api", + "verify": "GET /api/v1/auth/config on the enforced env returns features.ssoEnforced true with at least one provider listed", + "evidence": "the /auth/config response" + }, + { + "clause": "the first paint honors ssoEnforced — the password form never flashes before the SSO surface", + "oracle": "screenshot", + "verify": "the earliest settled screenshot shows the SSO surface and no email/password form; no intermediate frame showed the password wall", + "evidence": "first-paint screenshot(s)" + }, + { + "clause": "self-registration is hidden under enforcement, but the break-glass password link remains — enforcement hides the local form, it does not brick the env owner", + "oracle": "screenshot", + "verify": "no sign-up affordance; the 'use a password' break-glass link is present (AuthFeaturesConfigSchema's own contract for ssoEnforced)", + "evidence": "annotated login screenshot" + }, + { + "clause": "a hung sign-in recovers via the watchdog rather than stranding the page", + "oracle": "screenshot", + "verify": "after the watchdog window, the page offers a retry path (not a spinner forever)", + "evidence": "post-window screenshot" + }, + { + "clause": "both sides of the gate: without enforcement the email/password form renders on first paint and features.ssoEnforced is false/absent", + "oracle": "api", + "verify": "the unenforced env's /auth/config + first-paint screenshot show the password form present", + "evidence": "the second /auth/config response + screenshot" + } + ], + "negative": [ + "a password form flashing for even one settled frame before the SSO redirect/button is the FAIL this item exists for — capture it, do not rationalize it as a hydration artifact without ruling the trap out twice" + ], + "traps": ["hydration-race"], + "source": [ + "#3358 §6", + "packages/spec/src/system/auth-config.zod.ts (ssoOnlyMode + OS_AUTH_SSO_ONLY, break-glass endpoint stays enabled)", + "packages/spec/src/api/auth-endpoints.zod.ts (AuthFeaturesConfigSchema.ssoEnforced description)", + "packages/spec/src/kernel/public-auth-features.ts (ssoEnforced: login-surface flag; LoginForm hides password form + self-registration, break-glass link remains)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358", "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" } + ] + }, + { + "id": "identity-auth.phone-signin-surfaces", + "title": "A phone-based user's number shows across the identity surfaces, and the phone capability is advertised honestly", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "a seeded phone persona (Mei Phone — the seed itself failed silently until #3408; if she is absent, that regression has returned)", + "the exact seeded identity: usr_showcase_phone_demo / 'Mei Phone (demo)' / phone.demo@example.com / phone_number +8613800138000 (examples/app-showcase/src/security/seed-approval-demo.ts)" + ], + "knownGaps": [ + "the seeded persona's sign-in credential is not documented in the seed — driving an actual phone+password sign-in belongs to identity-auth.auth-method-matrix with a user created for that purpose; this item verifies the identity SURFACES" + ] + }, + "steps": [ + "boot the showcase; FIRST verify the seed via the API: GET /api/v1/data/sys_user filtered on phone_number = '+8613800138000' — this is the #3408 regression tripwire, checked before any browser work", + "GET /api/v1/auth/config and record features.phoneNumber (the flag that gates the create_user phoneNumber param — the original #2871 fix)", + "open the All Users admin list and locate 'Mei Phone (demo)'; screenshot the list row showing the phone number column", + "open her record detail and screenshot the highlights showing the number", + "as admin, run the create-user action for a NEW phone-carrying user (the phoneNumber param is visible only when features.phoneNumber is true) and screenshot the create-result dialog", + "verify the new user via the API read (phone_number persisted, phone_number_verified present as a column)", + "if the deployment has the phoneNumber plugin OFF: verify the param is absent from the create-user dialog instead, and record which side of the gate this run exercised" + ], + "acceptance": [ + { + "clause": "the seeded phone persona exists with the exact seeded number — server truth checked before the browser", + "oracle": "api", + "verify": "the sys_user read returns usr_showcase_phone_demo with phone_number +8613800138000", + "evidence": "the API response" + }, + { + "clause": "the phone number renders in all three surfaces: create-result dialog, All Users list row, and record detail", + "oracle": "screenshot", + "verify": "screenshots of dialog, list row, and detail highlights each showing the number", + "evidence": "the three screenshots" + }, + { + "clause": "a created phone user PERSISTS the number — the dialog is not a client echo", + "oracle": "api", + "verify": "API read of the newly created user shows phone_number stored (the better-auth phone-number plugin's unique sys_user.phone_number column)", + "evidence": "the post-create API read" + }, + { + "clause": "the capability is advertised honestly: features.phoneNumber in /api/v1/auth/config matches whether the plugin is configured, and the create-user phoneNumber param is visible exactly when the flag is true (#2871's gate)", + "oracle": "api", + "verify": "/auth/config flag vs the create-user dialog's param set (dialog checked only after a screenshot confirms it rendered)", + "evidence": "the /auth/config response + dialog screenshot" + } + ], + "negative": [ + "if the seeded persona is missing, verdict is FAIL on the seed (regression of #3408), not blocked — the boot log will carry the insert error", + "the create-user dialog offering a phoneNumber param while features.phoneNumber is false is the #2871 class of failure (UI advertising a capability the runtime lacks) — FAIL" + ], + "traps": ["seed-data-thin", "wrong-persona"], + "source": [ + "#3358 §6", + "#3408", + "examples/app-showcase/src/security/seed-approval-demo.ts (PHONE_DEMO_USER)", + "packages/spec/src/system/auth-config.zod.ts (phoneNumber plugin: unique phone_number + phone_number_verified columns)", + "packages/spec/src/kernel/public-auth-features.ts (phoneNumber gates sys_user.actions.create_user.params.phoneNumber — #2871)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358; encoded the #3408 silent-seed-failure as an explicit negative", "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" } + ] + }, + { + "id": "identity-auth.auth-method-matrix", + "title": "Every supported auth method signs in when enabled, is absent when disabled, and is advertised exactly as configured", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"], + "fixtures": { + "app": "showcase", + "requires": [ + "per-variant env boots: the matrix flips AuthPluginConfigSchema flags (phoneNumber, twoFactor, deviceAuthorization, oidcProvider) and the socialProviders/oidcProviders maps — each variant names which boot it needs", + "an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud NOT_SUPPORTED)" + ], + "knownGaps": [ + "magic-link and passkeys: the server flags exist (AuthPluginConfigSchema.magicLink/passkeys) but objectui ships NO login UI for either — advertised-but-unconsumed, tracked in objectui#2514. Run these variants as blocked(dependency, objectui#2514) at the browser lane; the flag-advertisement clause still applies", + "the two spec files disagree on the device-flow paths (packages/spec/src/api/auth-endpoints.zod.ts: /device/request, /device/token, /device/approve vs packages/spec/src/system/auth-config.zod.ts: /device/code, /device/token, /device, /device/approve, /device/deny) — the LIVE better-auth surface serves POST /device/code, /device/token, /device/approve, /device/deny and GET /device (auth-route-ledger.ts BETTER_AUTH_MOUNTED_SURFACE); trust the live routes and file the doc divergence", + "the password-reset path names ALSO diverge from the spec: AuthEndpointPaths.forgetPassword = '/forget-password' but the live catch-all serves POST /request-password-reset, POST /reset-password and GET /reset-password/:token (BETTER_AUTH_MOUNTED_SURFACE) — trust the live routes (this is the same divergence identity-auth.self-service-password-reset drives)" + ] + }, + "steps": [ + "for each variant: boot with the method configured ON, GET /api/v1/auth/config, and record the advertisement (features.* flag, emailPassword block, socialProviders list)", + "email+password: POST /api/v1/auth/sign-up/email (when sign-up enabled), POST /api/v1/auth/sign-in/email, GET /api/v1/auth/get-session, POST /api/v1/auth/sign-out — capture each response and the session cookie lifecycle", + "phone+password: create a phone-carrying user with a known password, POST the phone sign-in (better-auth /sign-in/phone-number surface — 'always works' when the plugin is on per the spec), verify get-session identifies the user", + "phone OTP with NO SMS service configured: request /phone-number/send-otp and capture the loud NOT_SUPPORTED rejection (never a silent 200, never a hang)", + "SSO/OIDC: with an oidcProviders[] entry configured, verify the login page shows the SSO button (features.sso is refined to 'usable' — ≥1 provider); drive the authorization-code round trip or cite the pinned OIDC dogfood test per rule 6", + "device authorization: with deviceAuthorization on, drive the RFC 8628 flow (POST /device/code, approve via /device/approve in a signed-in browser, poll /device/token) against the LIVE server's routes, recording which path spelling the server actually serves", + "2FA: with twoFactor on, enable it for a user (sys_user enable_two_factor action, gated on features.twoFactor), sign in, and verify the server-driven challenge (ADR-0069) interrupts before a session is granted", + "discovery documents: GET /.well-known/openid-configuration and GET /.well-known/oauth-authorization-server (both mounted at the APP ROOT, not under /api/v1/auth — RFC 8414 / OIDC require it, auth-plugin.ts) and record the issuer + advertised endpoints", + "self-service identity mutations: POST /api/v1/auth/change-email (authed) and POST /api/v1/auth/delete-user (authed) — capture each, plus the anonymous forge of both", + "for each variant: re-boot with the method OFF, then (a) GET /auth/config and confirm the advertisement is gone, (b) screenshot the login page and confirm the affordance is gone, (c) fire the method's endpoint anyway and confirm a server-side refusal", + "record the per-variant verdict table (on-side result, off-side result, advertisement parity)" + ], + "acceptance": [ + { + "clause": "advertisement parity per variant: /api/v1/auth/config reflects the configuration exactly — no method advertised that is off, none hidden that is on", + "oracle": "api", + "verify": "for every variant, the on-boot and off-boot /auth/config reads match the config that booted them", + "evidence": "per-variant /auth/config pairs" + }, + { + "clause": "email+password round trip: sign-in issues a session that get-session confirms and sign-out invalidates (a post-sign-out get-session no longer returns the user)", + "oracle": "api", + "verify": "the four-call sequence with response codes and the session state at each step", + "evidence": "the captured sequence" + }, + { + "clause": "phone+password signs in when the plugin is on — the number is a first-class identifier, not a display field", + "oracle": "api", + "verify": "the phone sign-in returns a session for the created user; get-session identifies them", + "evidence": "the sign-in + session trace" + }, + { + "clause": "phone OTP without a deliverable SMS service fails LOUDLY (NOT_SUPPORTED) — the capability degrades to a named error, never a silent success or a hang", + "oracle": "api", + "verify": "the send-otp response is a non-2xx carrying the not-supported error; features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)", + "evidence": "the rejection + the /auth/config read" + }, + { + "clause": "the login page shows exactly the enabled methods — per-variant presence when on, absence when off", + "oracle": "screenshot", + "verify": "login screenshots per boot, checked against that boot's /auth/config", + "evidence": "the screenshot set" + }, + { + "clause": "a disabled method is refused SERVER-SIDE, not merely hidden — UI absence is a client courtesy; the server is the authority (ADR-0057 D10)", + "oracle": "api", + "verify": "firing each disabled method's endpoint returns a non-2xx (the plugin's routes are absent or refuse)", + "evidence": "the forged-request responses" + }, + { + "clause": "2FA is a server-driven gate: with 2FA enabled for the user, password sign-in alone does NOT yield a usable session until the challenge completes", + "oracle": "api", + "verify": "the sign-in response demands the challenge; get-session before completing it does not return an authenticated user", + "evidence": "the challenge-flow trace" + }, + { + "clause": "the OIDC authorization-code flow is pinned by automation — run the pin and cite its output rather than re-deriving the round trip by hand", + "oracle": "test", + "verify": "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts passes on this build", + "evidence": "the test output" + }, + { + "clause": "the discovery documents are served and self-consistent: GET /.well-known/openid-configuration and GET /.well-known/oauth-authorization-server each return 200 JSON whose issuer and endpoint URLs point at the actually-mounted base (mounted at app root, not the /api/v1/auth prefix)", + "oracle": "api", + "verify": "both documents parse as JSON; issuer + authorization/token/jwks/userinfo endpoints resolve against the live server (e.g. jwks_uri answers the same key set as GET /api/v1/auth/jwks); a bespoke-issuer boot's issuer matches its configured base", + "evidence": "the two discovery-document responses + the jwks cross-check" + }, + { + "clause": "self-service change-email and delete-user are authed-only mutations: POST /api/v1/auth/change-email and POST /api/v1/auth/delete-user succeed for the signed-in user and are refused for the anonymous forge (better-auth also routes delete-user through GET /api/v1/auth/delete-user/callback confirmation before the row is gone)", + "oracle": "api", + "verify": "the authed change-email returns 2xx (and, if verification is required, does not flip the address until the confirmation link is followed); both endpoints answer 401 to the anonymous forge", + "evidence": "the authed responses + the two anonymous refusals" + } + ], + "negative": [ + "a silent 200 on any disabled method's endpoint is a FAIL — a gate that only hides the button is not a gate", + "phone OTP hanging or returning 2xx with no SMS service is a FAIL (the spec's own contract is 'loudly NOT_SUPPORTED')", + "ticking magic-link or passkeys as pass at the browser lane is a false positive — there is no UI to drive (objectui#2514); the honest verdict is blocked", + "a discovery document whose issuer/endpoints point at a base the server does not actually mount is a FAIL — a wrong .well-known breaks every downstream RP/relying party silently" + ], + "variants": [ + "email+password (POST /api/v1/auth/sign-in/email, /sign-up/email, /sign-out, /get-session)", + "phone+password (better-auth phone-number plugin sign-in surface)", + "phone OTP sign-in + reset (requires SMS service; loud NOT_SUPPORTED without — #2780)", + "enterprise SSO / generic OIDC (oidcProviders[] via genericOAuth; login button gated on usable providers)", + "social OAuth (socialProviders map, per-provider enabled)", + "device authorization grant (RFC 8628 — CLI/TV login)", + "two-factor (server-driven challenge, ADR-0069)", + "magic link (flag exists; no login UI — blocked, objectui#2514)", + "passkeys (flag exists; no login UI — blocked, objectui#2514)" + ], + "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts" }, + "traps": ["hydration-race", "dispatcher-vs-hono-route", "wrong-persona"], + "source": [ + "packages/spec/src/system/auth-config.zod.ts (AuthPluginConfigSchema: phoneNumber/twoFactor/deviceAuthorization/magicLink/passkeys; socialProviders; oidcProviders; EmailAndPasswordConfigSchema)", + "packages/spec/src/api/auth-endpoints.zod.ts (AuthEndpointPaths; AuthFeaturesConfigSchema; device-flow response schemas)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (BETTER_AUTH_MOUNTED_SURFACE: the live change-email/delete-user + /.well-known/* rows; auth-plugin.ts mounts the two discovery docs at app root)", + "packages/spec/src/kernel/public-auth-features.ts (flag semantics, gated inputs, objectui#2513/#2514 known gaps)", + "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new matrix item: per-method sign-in proof with both-sides gate checks and advertisement parity, grounded in the spec's plugin config + public feature registry", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-08", "change": "added the .well-known/openid-configuration + oauth-authorization-server discovery-document clause (issuer/endpoints match the mounted base, jwks cross-check) and self-service change-email + delete-user clauses; recorded the live-route divergences (device flow, password reset) from the spec paths (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.invitation-scope-gates", + "title": "Invitation issuance honors role-scope gates: delegated_admin can invite members but cannot mint admins; a plain member cannot invite at all", + "since": "v17", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": ["delegated_admin org member", "plain member", "tenant admin (setup only)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a multi-org boot with the organization capability on (the sys_invitation actions — invite_user/cancel_invitation/resend_invitation — are gated on features.organization)", + "a member holding the delegated_admin org role (the ADR-0105 D8 principal; the pinned dogfood test shows the setup: mint the org, set membership roles via the better-auth-managed writer)" + ] + }, + "steps": [ + "prefer the pin (rule 6): run packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts and capture its output — it proves the three-way contract end to end over real HTTP", + "browser lane, as the delegated_admin: open the invite affordance and issue a MEMBER-role invitation; capture the request and the created sys_invitation row via the data API", + "as the same principal, attempt an ADMIN-role invitation; capture the refusal", + "immediately read sys_invitation via the API and verify NO row (not even a pending one) was left behind by the refused attempt", + "as a plain member: verify the invite affordance is absent in the UI, then fire the invitation endpoint directly and capture the server refusal", + "as the invited member address, verify the pending invitation is visible/actionable where the product surfaces it, and check its status vocabulary against the spec enum", + "exercise cancel_invitation (or resend_invitation) on the pending row as the entitled persona and verify the status/state change via the API", + "read the delegable-scope surface that feeds the invite role picker: GET /api/v1/security/my-delegable-scope as the delegated_admin (strictly self-scoped, no target-user parameter — ADR-0090 D12 / ADR-0105 D8) and confirm the returned role set EXCLUDES admin-mintable roles; repeat as a plain member and confirm the scope is empty or the call is denied" + ], + "acceptance": [ + { + "clause": "a delegated_admin CAN issue a member invitation — the role reaches the endpoint (2xx) and a sys_invitation row exists with status pending", + "oracle": "api", + "verify": "the invite response + a sys_invitation read showing the pending row with the invitee email and role", + "evidence": "response + row read" + }, + { + "clause": "the SAME principal issuing role admin is refused — the role cap in beforeCreateInvitation holds (without it, admin-invite → auto-elevation → tenant admin is a four-step privilege escalation)", + "oracle": "api", + "verify": "the admin-role attempt returns non-2xx", + "evidence": "the refusal response" + }, + { + "clause": "the refusal is clean: NO invitation row is left behind by the refused attempt — no orphan pending admin invite that could later be accepted", + "oracle": "api", + "verify": "sys_invitation read immediately after the refusal shows no new row", + "evidence": "the read" + }, + { + "clause": "a plain member cannot invite AT ALL — proving it was the delegated_admin ROLE that opened the endpoint, not a general loosening", + "oracle": "api", + "verify": "the member's direct invitation request is refused server-side", + "evidence": "the forged-request response" + }, + { + "clause": "the UI shows invite affordances only to entitled personas — both sides captured", + "oracle": "screenshot", + "verify": "delegated_admin sees the invite affordance; plain member does not (screenshot first, then DOM)", + "evidence": "the two screenshots" + }, + { + "clause": "invitation lifecycle state uses the spec's vocabulary and transitions honestly (pending → accepted/rejected/expired; cancel/resend behave)", + "oracle": "api", + "verify": "row status is always one of the InvitationStatus enum values; the cancel/resend action's effect is visible in a fresh read", + "evidence": "the before/after reads" + }, + { + "clause": "the delegable-scope read is the picker's server truth: GET /api/v1/security/my-delegable-scope returns, for the delegated_admin, exactly the roles that principal may mint (admin-mintable roles absent) — so the UI cannot offer an admin invite it would then be refused for; a plain member's scope is empty or the call is denied", + "oracle": "api", + "verify": "the delegated_admin response's role list contains member but NOT admin; the plain member's response is empty/denied — cross-checked against the admin-role refusal proven above (client method security.describeDelegableScope, rest-route-ledger.ts)", + "evidence": "the two /security/my-delegable-scope responses" + } + ], + "negative": [ + "an admin-role invitation that returns success, or that leaves ANY row behind, is a FAIL of privilege-escalation severity — file immediately, P0-verify per RUNNER rule 7", + "UI-only enforcement (affordance hidden but the forged request succeeds) is a FAIL — the server is the authority (ADR-0057 D10)", + "my-delegable-scope returning admin (or any role the caller cannot actually mint) is a FAIL — the picker would offer an invite the endpoint then refuses, and worse, a client that trusts the scope could try to mint it" + ], + "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts" }, + "traps": ["wrong-persona", "dispatcher-vs-hono-route"], + "source": [ + "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/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" } + ] + }, + { + "id": "identity-auth.admin-lifecycle-operations", + "title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": ["platform admin", "target user", "non-admin forger"], + "fixtures": { + "app": "showcase", + "requires": [ + "the better-auth admin plugin enabled (plugins.admin: true) — it exposes /admin/ban-user, /admin/unban-user, /admin/set-user-password, /admin/impersonate-user, /admin/stop-impersonating, /admin/list-users, /admin/create-user, /admin/set-role, /admin/remove-user, /admin/revoke-user-session(s) under the auth route and augments sys_user with role/banned/ban_reason/ban_expires and sys_session with impersonated_by", + "a disposable target user with a known password (create one; do not ban a seeded persona other items depend on)" + ] + }, + "steps": [ + "GET /api/v1/auth/config and confirm features.admin is advertised (opt-in flag; it gates the sys_user ban/unban/set-password/impersonate actions)", + "as platform admin, GET /api/v1/auth/admin/list-users and record the roster the admin surface reads from", + "create a login-capable user via POST /api/v1/auth/admin/create-user with an EXPLICIT password AND generatePassword:true set (the console dialog sends both — generatePassword defaults true, the input is labelled 'leave empty to generate'); then sign in as that user with the EXPLICIT password to prove it won over the generated one (#3031/#3033)", + "as platform admin, ban the target user with a reason via the sys_user action (or POST /api/v1/auth/admin/ban-user); read sys_user back and record banned/ban_reason", + "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 authed request (get-session) must 401 mid-flight — the kill is immediate, not deferred to expiry", + "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)", + "as a NON-admin, fire each of /admin/ban-user, /admin/list-users, /admin/create-user, /admin/set-role, /admin/remove-user, /admin/revoke-user-sessions directly and capture every refusal", + "run the audit-trail pin and capture its output for the attribution clause" + ], + "acceptance": [ + { + "clause": "ban persists, enforces, and is reversible: after ban, sys_user shows banned + ban_reason and the banned user's sign-in is refused with a named error; after unban, the same credentials sign in again — the gate is reversible, not a tombstone", + "oracle": "api", + "verify": "the sys_user read + refused sign-in after the ban, then the successful sign-in after the unban", + "evidence": "row read + the refused sign-in + the post-unban sign-in" + }, + { + "clause": "set-user-password rotates the credential: new password works, old password is refused", + "oracle": "api", + "verify": "both sign-in attempts captured after the rotation", + "evidence": "the two responses" + }, + { + "clause": "admin create-user mints a login-capable account and explicit-password-wins: a user created with BOTH an explicit password and generatePassword:true signs in with the EXPLICIT password (the generated one was never applied) — and admin/create-user leaves exactly the identity rows it should (sys_user + its credential sys_account)", + "oracle": "api", + "verify": "the create-user response + a successful sign-in with the explicit password; the generated password (never returned to the caller for an explicit request) does not sign in (#3031/#3033, admin-user-endpoints.ts resolvePassword)", + "evidence": "the create response + the two sign-in attempts" + }, + { + "clause": "set-role changes gate outcomes, not just a column: after POST /admin/set-role the target's access to a role-gated operation flips (grants what the new role allows, revokes what it removes) — the role write is authoritative for authorization, verified by re-driving the gated call", + "oracle": "api", + "verify": "the same gated request returns 2xx vs 403 before/after the role change, as the target", + "evidence": "the two gated-request responses bracketing the set-role" + }, + { + "clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a session that answered get-session a moment earlier now 401s immediately after the admin revoke — not at token expiry", + "oracle": "api", + "verify": "get-session as the target: 2xx before the admin revoke, 401 on the very next call after it", + "evidence": "the before/after get-session pair" + }, + { + "clause": "engine cascade exemption (§A5): removing a user who OWNS rows clears owner_id to null on those rows via the engine's referential-integrity FK clear — the owner-anchor transfer guard does NOT veto this system-context cascade write (it rides a server-DERIVED marker, __referentialFieldClear, that cannot be forged from a request), and the owned row survives with owner_id null rather than the delete aborting", + "oracle": "api", + "verify": "read the owned row after remove-user: it exists, owner_id is null; the remove-user call itself returned 2xx (not a guard-abort). Cross-checked by packages/plugins/plugin-security/src/security-plugin.test.ts '[#3023] an engine referential FK clear … is exempt'", + "evidence": "the owned-row read (owner_id null) + the remove-user response" + }, + { + "clause": "impersonation is attributed: the impersonated session records impersonated_by, and stop-impersonating returns the admin to their own session", + "oracle": "api", + "verify": "session read during impersonation shows impersonated_by = the admin; after stopping, get-session returns the admin again", + "evidence": "the two session reads" + }, + { + "clause": "the console makes the impersonation state visible while it is active — support sessions must not be silent", + "oracle": "screenshot", + "verify": "screenshot during impersonation shows the impersonated identity (and any impersonation indicator the console renders)", + "evidence": "the screenshot" + }, + { + "clause": "admin operations leave an attributable audit trail — pinned by automation", + "oracle": "test", + "verify": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts passes on this build", + "evidence": "the test output" + }, + { + "clause": "the gate holds both ways for EVERY admin operation: a non-admin's direct call to /admin/ban-user, /admin/list-users, /admin/create-user, /admin/set-role, /admin/remove-user and /admin/revoke-user-sessions is each refused server-side (better-auth enforces the platform admin role internally)", + "oracle": "api", + "verify": "each forged call returns non-2xx and the target's sys_user row (and session, and roster) is unchanged", + "evidence": "the six refusals + the unchanged-state reads" + } + ], + "negative": [ + "a non-admin forged admin operation succeeding is a FAIL of the highest severity — apply RUNNER rule 7 (independent re-derivation) before acting on it", + "a ban that hides the user in the UI while their sign-in still works is a FAIL — the sign-in refusal is the enforcement, not the list filter", + "revoke-user-sessions that only stops NEW logins while the existing live session keeps answering is a FAIL — the contract is an immediate kill", + "remove-user aborting because the owner-anchor guard vetoed the owner_id-null cascade (instead of exempting the engine FK clear) is the #3023 regression returned — FAIL; equally, a create-user that applies the GENERATED password when an explicit one was supplied is the #3031 failure — FAIL" + ], + "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts" }, + "traps": ["wrong-persona", "shared-browser-tab"], + "source": [ + "packages/spec/src/system/auth-config.zod.ts (admin plugin: endpoint list, sys_user role/banned/ban_reason/ban_expires, sys_session.impersonated_by)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (BETTER_AUTH_MOUNTED_SURFACE admin/* rows: list-users, create-user, set-role, remove-user, revoke-user-session(s))", + "packages/plugins/plugin-auth/src/admin-user-endpoints.ts (create-user resolvePassword: explicit password wins over generatePassword — #3031/#3033; leaves sys_user + credential sys_account)", + "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" + ], + "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" } + ] + }, + { + "id": "identity-auth.session-list-revoke", + "title": "Active-session list is owner-scoped, and each revoke primitive terminates exactly the sessions it names", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["user A (two devices)", "user B (unrelated, second live session)", "platform admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "email+password sign-in enabled (stock showcase) so a user can hold two concurrent sessions from two browser contexts", + "a second unrelated user (B) signed in, to prove the list is owner-scoped (RLS), not a global session dump", + "admin access to the Setup → Sessions surface (nav_sessions, sys_session all_sessions view) for the all-users clause" + ] + }, + "steps": [ + "sign user A in from two independent browser contexts (device-1, device-2), and separately sign user B in once — three live sessions total", + "as A, GET /api/v1/auth/list-sessions (client auth.sessions.list) AND load the Account 'Active Sessions' surface (sys_session `mine` view, filter user_id = {current_user_id}); screenshot the list after it renders", + "cross-check the data-API read: GET /api/v1/data/sys_session as A returns ONLY A's rows — B's session must be invisible (RLS), even though sys_session is a shared managed-by-better-auth table", + "revoke device-2 specifically: POST /api/v1/auth/revoke-session keyed on that session's TOKEN (the object action revoke_session uses recordIdParam:'token' — better-auth keys off the token, not the row id); then issue an authed request from device-2 and capture the 401", + "confirm device-1 is untouched: an authed request from device-1 still 2xx", + "from device-1, invoke revoke-other-sessions (POST /api/v1/auth/revoke-other-sessions, the 'Sign out other devices' toolbar action): every OTHER session for A dies, device-1 survives; re-read list-sessions and confirm exactly one row (device-1) remains", + "as admin, open Setup → Sessions (all_sessions view) and confirm it lists rows across ALL users (A and B both present, user_id column shown), with the revoked_at / revoke_reason fields visible on terminated rows", + "attempt a direct write to sys_session via the data API (PATCH/DELETE /api/v1/data/sys_session/{id}) and capture the refusal — the table is read-only over the data API (apiMethods ['get','list'], writes 405 before 403)" + ], + "acceptance": [ + { + "clause": "the active-session list is owner-scoped: A's list-sessions and the Account sessions view show BOTH of A's sessions and NONE of B's — the shared sys_session table is RLS-filtered to the caller", + "oracle": "api", + "verify": "GET /api/v1/auth/list-sessions and GET /api/v1/data/sys_session as A return A's two rows only; B's row id (known from B's own read) is absent", + "evidence": "A's list responses + B's row id proving the exclusion" + }, + { + "clause": "the browser Active Sessions surface renders the owner's sessions (mine view) — screenshot-confirmed before any DOM read", + "oracle": "screenshot", + "verify": "the Account sessions view shows A's two device rows with ip_address / created_at / expires_at columns; the count matches the API read", + "evidence": "the sessions-list screenshot + API count" + }, + { + "clause": "revoke-session terminates exactly the named session: after revoking device-2 by token, device-2's next request 401s and device-1 keeps working — the revoke is keyed on the token, one session, not all", + "oracle": "api", + "verify": "device-2 authed request returns 401 post-revoke; device-1 authed request still 2xx", + "evidence": "the two post-revoke request traces" + }, + { + "clause": "revoke-other-sessions keeps ONLY the calling session: invoked from device-1, it kills every other session for A and leaves device-1 alive; the post-call list-sessions has exactly one row", + "oracle": "api", + "verify": "list-sessions after the call returns one row (device-1); a request from any previously-other session 401s", + "evidence": "the post-call list + a 401 from a killed session" + }, + { + "clause": "the admin Sessions surface lists ALL users' sessions with revoke metadata: the all_sessions view shows rows for both A and B (user_id column) and surfaces revoked_at / revoke_reason on terminated rows", + "oracle": "screenshot", + "verify": "Setup → Sessions shows cross-user rows; a revoked row shows revoked_at set and a revoke_reason from the {idle_timeout, absolute_max, concurrent_cap, admin} vocabulary", + "evidence": "the admin sessions screenshot + a data-API read of a revoked row" + }, + { + "clause": "sys_session is read-only over the data API — a forged direct write is refused (405 before 403), so revocation only happens through the auth endpoints, never a raw row edit", + "oracle": "api", + "verify": "PATCH/DELETE /api/v1/data/sys_session/{id} returns 405 (method not allowed — apiMethods ['get','list'], identity write guard ADR-0092 D2)", + "evidence": "the forged-write response" + } + ], + "negative": [ + "A's session list returning B's rows (or a global session dump) is an RLS-breach FAIL — the shared table must be owner-filtered", + "revoke-session that signs A out of BOTH devices (killing device-1 too) is a FAIL — it must terminate exactly the named token", + "revoke-other-sessions that also kills the calling session, or that leaves a supposedly-revoked session still answering, is a FAIL", + "a revoked session that keeps answering authed requests until token expiry is a FAIL — revocation is immediate, the 401 is the enforcement not the list filter" + ], + "traps": ["wrong-persona", "shared-browser-tab"], + "source": [ + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (GET list-sessions=auth.sessions.list, POST revoke-session=auth.sessions.revoke, revoke-other-sessions=auth.sessions.revokeOthers, revoke-sessions=auth.sessions.revokeAll)", + "packages/platform-objects/src/identity/sys-session.object.ts (mine view filter user_id={current_user_id}; all_sessions admin view; revoked_at/revoke_reason fields ADR-0069 D4; revoke_session action recordIdParam:'token'; apiMethods ['get','list'] — writes 405 before 403, #1591/ADR-0092 D2)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_sessions → Setup Sessions, objectName sys_session)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: owner-scoped active-session list (RLS) + per-primitive revoke semantics (revoke-session by token, revoke-other-sessions keeps current) + admin all-sessions view with revoked_at/revoke_reason, grounded in the auth route ledger and sys_session object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.api-key-ui-lifecycle", + "title": "Personal API key: minted show-once on Connect-an-Agent, authenticates as its owner, revoke kills it and restore brings it back — mine-view scoped", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["a signed-in member (key owner)", "platform admin", "the key itself (machine caller via x-api-key)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the developer Integrations page (AgentConnectSection) reachable for the signed-in user — POST /api/v1/keys is the mint route (client keys.create) and it is not admin-gated (the key acts as the caller)", + "at least one apiEnabled showcase data object to call with the key (e.g. showcase_task)" + ] + }, + "steps": [ + "as a signed-in member, open the developer Integrations page → 'Connect an AI agent (MCP)' (AgentConnectSection), type a key name and click Generate key; screenshot the show-once dialog", + "capture the mint response: POST /api/v1/keys returns data.key (the raw secret) exactly once, plus id / name / prefix", + "record the raw secret, close the dialog, then re-open the API Keys list (sys_api_key `mine` view) and confirm the row shows only the prefix — the raw key is never shown again (the stored `key` column is hashed + hidden)", + "call a data route as the machine caller: GET /api/v1/data/showcase_task with header x-api-key: (NOT a session cookie/Bearer) and capture the 200 + that the rows returned are the OWNER's row-level-security view (the key acts as the user)", + "revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 401", + "restore via restore_api_key (PATCH … {revoked:false}); re-call and capture the 200 again — the lifecycle is reversible", + "mine-view scoping: sign in as a DIFFERENT member and confirm the sys_api_key `mine` view (filter user_id={current_user_id}) does NOT list the first user's key; then as admin open Setup → API Keys (the `all_keys` view, nav gated on manage_platform_settings) and confirm every user's key is listed", + "attempt to read the raw secret back via the data API (GET /api/v1/data/sys_api_key/{id}) and confirm the `key` field is absent/hidden — only the prefix is ever returned" + ], + "acceptance": [ + { + "clause": "the secret is shown exactly once: the mint response carries data.key and the show-once dialog displays it, but every subsequent read (list row, get-by-id) returns only the prefix — the stored key is hashed", + "oracle": "api", + "verify": "POST /api/v1/keys response has a raw `key`; a follow-up GET /api/v1/data/sys_api_key/{id} has no raw key, only prefix (the hidden hashed `key` column never serializes)", + "evidence": "the mint response + the follow-up read + the dialog screenshot" + }, + { + "clause": "the key authenticates as its owner: x-api-key on a data route returns 200 and the row set is the OWNER's RLS view — a machine credential, carrying the user's permissions, not a superuser bypass", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_task with x-api-key returns 200; the rows match what the owner sees with a session (not more)", + "evidence": "the x-api-key request trace + a session-read comparison" + }, + { + "clause": "revoke is immediate and enforced server-side: after revoke_api_key the same x-api-key call 401s on the very next request", + "oracle": "api", + "verify": "the post-revoke x-api-key request returns 401", + "evidence": "the revoke PATCH response + the 401" + }, + { + "clause": "restore is reversible: restore_api_key flips revoked back to false and the key authenticates again (200)", + "oracle": "api", + "verify": "the post-restore x-api-key request returns 200", + "evidence": "the restore PATCH + the 200" + }, + { + "clause": "keys are mine-view scoped: the sys_api_key `mine` view lists only the caller's own keys; a second user cannot see the first's, and only the admin all_keys view (manage_platform_settings) shows every user's keys", + "oracle": "api", + "verify": "the second member's mine-view read excludes the first key's id; the admin all_keys read includes it", + "evidence": "the two mine-view reads + the admin read" + }, + { + "clause": "the show-once dialog and the API Keys list render correctly — screenshot-confirmed", + "oracle": "screenshot", + "verify": "the generate dialog shows the copyable secret with the 'not be shown again' warning; the re-opened list shows the prefix-only row", + "evidence": "the two screenshots" + } + ], + "negative": [ + "the raw secret being readable a second time (in a list row, a get-by-id, or a re-opened dialog) is a FAIL — show-once means the hash is all the server keeps", + "a revoked key still returning 200 on a data route is a FAIL — revocation must be enforced at auth, not only hidden in the list", + "an api key that reads MORE than its owner's RLS view (a superuser bypass) is a FAIL — the key carries the user's permissions", + "one user's key appearing in another user's mine view is an RLS FAIL" + ], + "traps": ["wrong-persona", "hydration-race"], + "source": [ + "objectui apps/console/src/pages/developer/AgentConnectSection.tsx (ADR-0036 Phase 2b: POST /api/v1/keys mint, show-once dialog, x-api-key connect steps)", + "packages/runtime/src/route-ledger.ts (POST /keys → client keys.create)", + "packages/platform-objects/src/identity/sys-api-key.object.ts (revoke_api_key/restore_api_key actions PATCH /api/v1/data/sys_api_key/{id} bodyExtra revoked; mine view user_id={current_user_id} vs all_keys; hashed hidden `key`, visible `prefix`; apiMethods ['get','list'])", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_api_keys requiredPermissions ['manage_platform_settings'] — admin all-view)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: personal API key mint (show-once, POST /api/v1/keys) → authenticate-as-owner via x-api-key → revoke 401 → restore 200, plus mine-view vs admin all-view scoping, grounded in AgentConnectSection + sys_api_key object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.self-service-profile-password", + "title": "Self-service profile: name + avatar edits persist through real storage, and password change verifies the current password (passwordless users get set-initial)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["a signed-in email+password user", "a signed-in passwordless (SSO-only) user"], + "fixtures": { + "app": "showcase", + "requires": [ + "the Account app Profile page (objectui apps/console ProfilePage.tsx) reachable for a signed-in user", + "a working file-storage backend so the avatar upload writes a real object (useUpload → presigned/committed file); if storage is unconfigured the avatar clause is blocked(fixture) not faked", + "for the set-initial-password clause: a user with NO local password (signed in via SSO/social) — hasLocalPassword() returns false; on stock showcase this needs a social/OIDC provider, so that clause is blocked(fixture) same class as sso-enforced unless a passwordless user can be provisioned" + ], + "knownGaps": [ + "the set-initial-password branch needs a passwordless (SSO-only) user; stock showcase ships no configured IdP, so provision one or run that clause blocked(fixture)" + ] + }, + "steps": [ + "sign in as the email+password user and open the Account Profile page; screenshot the identity card (name, email, role badge)", + "change the name field and Save (handleSave → auth.updateUser({name}) → POST /api/v1/auth/update-user); re-read GET /api/v1/auth/get-session and confirm the new name; reload the page and confirm the header/avatar-fallback reflect it (persisted, not just local state)", + "upload an avatar (the hidden file input, data-testid profile-avatar-file): useUpload writes the file to storage, then updateUser({image: }); capture the storage write (presigned/commit) and confirm get-session now carries image = that url", + "remove the avatar (profile-avatar-remove-btn → updateUser({image:null})); confirm get-session image is cleared and the fallback initials render again", + "change the password: enter the correct current password + a new one (PasswordCard → auth.changePassword(current,new) → POST /api/v1/auth/change-password); sign out and sign in with the NEW password (2xx), then confirm the OLD password is refused", + "negative: attempt change-password with a WRONG current password and capture the refusal — the current-password check is server-enforced, not just a UI confirm", + "confirm email is immutable on this surface (the email input is disabled, 'Email cannot be changed') — self-service email change is a distinct endpoint (identity-auth.auth-method-matrix covers /auth/change-email)", + "passwordless branch (if a set-initial-capable user exists): as the SSO-only user, the card renders 'Set Local Password' (hasLocalPassword false) — set an initial password (setInitialPassword → no current-password field) and then sign in with email+password to prove the local credential now exists" + ], + "acceptance": [ + { + "clause": "a name change persists server-side: after Save, get-session returns the new name and it survives a reload — updateUser wrote it, the header is not just local React state", + "oracle": "api", + "verify": "GET /api/v1/auth/get-session after the POST /api/v1/auth/update-user shows the new name; a reloaded page shows it too", + "evidence": "the update + get-session responses + the reloaded screenshot" + }, + { + "clause": "avatar upload is a real storage write, not a data: URL: useUpload commits a file object and updateUser({image}) stores its url; get-session reflects the url and the avatar renders", + "oracle": "network", + "verify": "the upload network trace shows a storage presign/commit returning a URL; get-session image equals that URL; the rendered matches", + "evidence": "the upload trace + get-session + avatar screenshot" + }, + { + "clause": "avatar remove clears the image: updateUser({image:null}) empties the stored image and the initials fallback returns", + "oracle": "api", + "verify": "post-remove get-session image is null/empty; the card shows getUserInitials fallback", + "evidence": "the get-session + the screenshot" + }, + { + "clause": "password change verifies the CURRENT password and rotates the credential: the correct current password + new one succeeds, the new password then signs in and the old one is refused", + "oracle": "api", + "verify": "change-password 2xx; sign-in with new password 2xx; sign-in with old password non-2xx", + "evidence": "the change response + the two sign-in attempts" + }, + { + "clause": "a wrong current password is refused server-side — the verification is real, not a client-only confirm", + "oracle": "api", + "verify": "change-password with an incorrect current password returns non-2xx and the credential is unchanged (old password still signs in)", + "evidence": "the refusal + a subsequent old-password sign-in still working" + }, + { + "clause": "a passwordless user gets set-initial-password, not change-password: with hasLocalPassword false the card omits the current-password field, setInitialPassword creates the local credential, and email+password sign-in then works", + "oracle": "api", + "verify": "the set-initial call 2xx with no current-password; a subsequent email+password sign-in for that user 2xx (was impossible before)", + "evidence": "the set-initial response + the new sign-in" + } + ], + "negative": [ + "a name/avatar 'save' that updates the header but does NOT persist (gone after reload) is a FAIL — updateUser must write server-side", + "an avatar stored as an inline data: URL rather than a committed storage object is a FAIL (the item asserts a real storage write)", + "change-password succeeding with a WRONG current password is a security FAIL — the current-password check must be server-enforced", + "offering the passwordless user a change-password form with a required current password they don't have (locking them out of setting one) is a FAIL — set-initial is the correct branch" + ], + "traps": ["hydration-race", "stale-console-bundle"], + "source": [ + "objectui apps/console/src/pages/system/ProfilePage.tsx (updateUser name/image; useUpload avatar; PasswordCard changePassword vs setInitialPassword gated on hasLocalPassword; email immutable; data-testids profile-avatar-file/-upload-btn/-remove-btn)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /api/v1/auth/update-user=auth.updateUser, POST /api/v1/auth/change-password=auth.changePassword, GET /api/v1/auth/get-session=auth.me)", + "packages/platform-objects/src/identity/sys-account.object.ts (previous_password_hashes ring — ADR-0069 D1 reuse-prevention backs change-password)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: self-service name + avatar (real storage write, persists) and password change with current-password verification, plus the passwordless set-initial branch, grounded in ProfilePage.tsx + the auth route ledger (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.org-membership-team-management", + "title": "Setup Organization page resolves the active org and drives member/invitation/team management through the better-auth org endpoints — non-admins refused server-side", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["tenant admin / org owner", "a target org member", "a non-admin org member (forger)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the organization capability mounted (always mounted per ADR-0081 D1; plugin-auth's default-org bootstrap guarantees an org to manage) and a session with an active organization so {current_org_id} resolves", + "at least two members in the org so role changes and removals have a target that is not the admin" + ] + }, + "steps": [ + "sign in as the org owner/admin and open Setup → People & Org → Organization (nav_organization: type object, objectName sys_organization, recordId {current_org_id}, ADR-0081); screenshot the org record page and confirm {current_org_id} resolved to the session's active org (not the list fallback)", + "confirm the record page exposes the Members / Invitations / Teams tabs with the better-auth row actions (GET list-members, list-invitations, list-teams feed them)", + "change a member's role: POST /api/v1/auth/organization/update-member-role (client organizations.updateMemberRole) to one of the 4-name vocabulary {owner, admin, member, guest}; read the membership back and confirm the new role", + "prove the role change bites: an operation the new role gates flips outcome for that member (e.g. promote to admin → an org-admin-only action now permitted; demote → refused)", + "rename the organization: POST /api/v1/auth/organization/update (organizations.update) with a new name; re-read GET get-full-organization and confirm the rename persisted and the nav label follows", + "remove a member: POST /api/v1/auth/organization/remove-member (organizations.removeMember); confirm the member's org-scoped access SHRINKS — a resource they could read as a member now refuses", + "create a team + add members: POST /api/v1/auth/organization/create-team (organizations.teams.create), then POST /api/v1/auth/organization/add-team-member (organizations.teams.addMember) for two users; read sys_team_member and confirm two join rows exist (unique on team_id+user_id)", + "both-sides gate: as a NON-admin member, fire update-member-role / remove-member / update / create-team / add-team-member directly and capture each server refusal; screenshot that the management affordances are absent in the non-admin's UI" + ], + "acceptance": [ + { + "clause": "{current_org_id} resolves to the session's active organization: the Organization nav opens that org's record page (Members/Invitations/Teams tabs), not the raw sys_organization list — the ADR-0081 active-org token is wired", + "oracle": "screenshot", + "verify": "the org record page renders for the active org id (cross-checked against GET /api/v1/auth/organization/get-active-member / get-full-organization); the three management tabs are present", + "evidence": "the org-page screenshot + the get-active/get-full response" + }, + { + "clause": "update-member-role writes a role from the 4-name vocabulary and it bites: the membership read shows the new role (one of owner/admin/member/guest) 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", + "evidence": "the membership read + the bracketing gated requests" + }, + { + "clause": "rename via organization/update persists: get-full-organization returns the new name and the surface follows", + "oracle": "api", + "verify": "GET get-full-organization after the update shows the renamed org", + "evidence": "the before/after org reads" + }, + { + "clause": "remove-member shrinks access: after removal the ex-member is refused a resource they could reach as a member — removal is an authorization change, not just a roster edit", + "oracle": "api", + "verify": "an org-scoped request that succeeded for the member returns a refusal after remove-member", + "evidence": "the before/after member requests" + }, + { + "clause": "create-team + add-team-member land real join rows: sys_team_member has one row per (team, user), created through the better-auth org endpoints (generic CRUD on the managed table is suppressed)", + "oracle": "api", + "verify": "GET /api/v1/data/sys_team_member (or list-team-members) shows the two membership rows for the new team; the (team_id,user_id) pairs match", + "evidence": "the sys_team_member read" + }, + { + "clause": "the gate holds both ways: a non-admin's direct update-member-role / remove-member / update / create-team / add-team-member calls are each refused server-side, and the management affordances are absent in the non-admin UI", + "oracle": "api", + "verify": "each forged non-admin call returns non-2xx and leaves membership/org/team state unchanged; the non-admin org page shows no management actions", + "evidence": "the forged-request responses + the non-admin screenshot" + } + ], + "negative": [ + "an org management surface where the affordance is hidden but the forged endpoint succeeds for a non-admin is a FAIL — the server is the authority (ADR-0057 D10)", + "remove-member that drops the roster row but leaves the ex-member's org-scoped access intact is a FAIL — removal must change authorization", + "a role written outside the {owner, admin, member, guest} vocabulary, or a role change that does not flip any gate, is a FAIL", + "the Organization nav landing on the raw sys_organization list because {current_org_id} did not resolve (when an active org exists) is a FAIL of the ADR-0081 wiring" + ], + "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"], + "source": [ + "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_organization recordId {current_org_id}, ADR-0081; Teams/Invitations always mounted per ADR-0081 D1)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (organization family: update-member-role, remove-member, update, create-team, add-team-member, list-members/teams/invitations, get-active-member, get-full-organization)", + "packages/spec/src/identity/organization.zod.ts (MemberSchema role vocabulary: owner/admin/member/guest)", + "packages/platform-objects/src/identity/sys-team-member.object.ts (add_team_member/remove_team_member actions → organization/add-team-member; unique team_id+user_id; requiresFeature organization)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: Setup Organization page {current_org_id} resolution (ADR-0081) with Members/Invitations/Teams tabs, update-member-role (4-name vocab)/remove-member/rename, create-team + add-team-member → sys_team_member rows, non-admin refused server-side (PENDING-GAPS §B). Teams membership deep-tested in identity-auth.teams-bu-membership; org-member management stays here", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.teams-bu-membership", + "title": "Teams and the Business Unit tree: memberships land real rows, and a BU placement widens/narrows a scoped persona's read along the tree", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["org admin", "two members to place on a team", "a scope-limited persona whose read follows the BU tree"], + "fixtures": { + "app": "showcase", + "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: a sharing/scope configuration that actually consumes the BU tree (recipient_type business_unit sharing rules, or a scope-depth persona) — if no showcase geometry consumes BU membership, run that clause blocked(fixture) and record it" + ], + "knownGaps": [ + "whether a stock showcase persona's read is scoped BY the business-unit tree depends on the seeded sharing/scope config; if none consumes it, the tree-widening clause is blocked(fixture) — the team-membership and BU-placement clauses still run" + ] + }, + "steps": [ + "create a team via POST /api/v1/auth/organization/create-team (or the sys_team create_team action) and add two members via POST /api/v1/auth/organization/add-team-member; read sys_team_member and confirm two rows (unique team_id+user_id)", + "remove one via remove-team-member and confirm the join row is gone (the endpoint keys on the (teamId,userId) pair, not the row id)", + "create a CHILD sys_business_unit under an existing root: POST /api/v1/data/sys_business_unit with kind (company|division|department|office|cost_center) and parent_business_unit_id = the root's id; confirm it appears in the Org Chart tree view under its parent", + "place a user in the child BU: create a sys_business_unit_member row (business_unit_id, user_id, function_in_business_unit member|lead|deputy, is_primary); confirm the placement via a data-API read", + "if BU scope geometry is configured: as the scope-limited persona, record the row set visible BEFORE the placement, then place the persona (or a record they can see) into the child BU and re-read — the visible set should WIDEN or NARROW along the tree per the geometry (cross-ref identity-auth via access-security.scope-depth-asymmetry which owns the depth matrix)", + "move the child BU to a different parent (re-parent parent_business_unit_id) and, if geometry consumes it, re-read the scoped persona's rows to confirm the read follows the new tree position", + "negative: attempt the team mutations and the BU writes as a non-admin and capture the refusals" + ], + "acceptance": [ + { + "clause": "team membership lands real rows: create-team + add-team-member produce exactly one sys_team_member per (team,user), and remove-team-member deletes exactly that pair", + "oracle": "api", + "verify": "sys_team_member reads before/after each mutation; the (team_id,user_id) rows match the two added members and the removal drops exactly one", + "evidence": "the sys_team_member reads" + }, + { + "clause": "a child business unit attaches to the tree: the new sys_business_unit carries parent_business_unit_id = the root and renders under it in the Org Chart tree view", + "oracle": "api", + "verify": "GET /api/v1/data/sys_business_unit for the child shows the parent id; the tree view (org_chart) renders it nested (screenshot after render)", + "evidence": "the BU read + the org-chart screenshot" + }, + { + "clause": "a user placement is a real sys_business_unit_member row with its function/primary attributes", + "oracle": "api", + "verify": "the sys_business_unit_member read shows business_unit_id + user_id + function_in_business_unit + is_primary", + "evidence": "the membership read" + }, + { + "clause": "when scope geometry consumes the BU tree, a placement changes a scoped persona's visible rows along the tree — widening (placed higher / into a parent that expands subordinates) or narrowing accordingly; re-parenting moves the read with it", + "oracle": "api", + "verify": "the scoped persona's row set before vs after the placement/re-parent differs exactly by the subtree the geometry expands; if no geometry consumes BU membership this clause is blocked(fixture) and recorded", + "evidence": "the before/after scoped reads (or the recorded block)" + }, + { + "clause": "team and BU mutations are admin-gated: a non-admin's create-team/add-team-member and BU writes are refused server-side", + "oracle": "api", + "verify": "the forged non-admin calls return non-2xx and leave sys_team_member / sys_business_unit(_member) unchanged", + "evidence": "the refusals + the unchanged reads" + } + ], + "negative": [ + "add-team-member that does not create a sys_team_member row (or creates duplicates past the unique team_id+user_id constraint) is a FAIL", + "a BU placement that the scope geometry claims to consume but which does NOT move the scoped persona's read is a FAIL — the tree must be load-bearing, not decorative", + "silently degrading a BU scope to own/org when an intermediate depth is authored is the ADR-0049 loud-degradation concern — record it (see PENDING-GAPS §H five-depth note), do not tick it green" + ], + "traps": ["wrong-persona", "seed-data-thin", "hydration-race"], + "source": [ + "packages/platform-objects/src/identity/sys-team-member.object.ts (add_team_member/remove_team_member → organization/add-team-member|remove-team-member; unique team_id+user_id)", + "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)" + ], + "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" } + ] + }, + { + "id": "identity-auth.self-service-password-reset", + "title": "Forgot-password: request → token captured at the dev mail transport → reset; old password refused, expired/reused token refused loudly", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["anonymous visitor (holds no session)", "the account owner (before and after reset)"], + "fixtures": { + "app": "showcase", + "requires": [ + "email+password sign-in enabled and an email transport configured — for a hermetic run use the dev `log` mail transport (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport) so the reset email (and its token) is captured in the transport output rather than actually sent", + "a target user with a known current password" + ], + "knownGaps": [ + "capturing the reset TOKEN requires reading it from the dev mail transport (LogTransport) output — if the deployment sends via a real provider with no capture hook, the token-capture step is blocked(fixture); the request/refusal clauses that don't need the token still run" + ] + }, + "steps": [ + "as anonymous, request a reset: POST /api/v1/auth/request-password-reset with the target email (NOTE the live route is request-password-reset / reset-password — the spec's AuthEndpointPaths says /forget-password, a divergence; trust the live server per RUNNER, and file the doc divergence)", + "capture the reset email at the dev mail transport (LogTransport) and extract the token (the link is GET /api/v1/auth/reset-password/:token)", + "complete the reset: POST /api/v1/auth/reset-password with the token + a new password; capture the response", + "sign in with the NEW password (2xx) and confirm the OLD password is now refused", + "reuse the SAME token a second time (POST /api/v1/auth/reset-password again) and capture the loud refusal — a consumed token must not reset again", + "request a fresh reset, let the token expire (or use a tampered/garbage token) and confirm the reset is refused with a named error, never a silent success", + "negative: request-password-reset for an unknown email should not leak whether the account exists (anti-enumeration) — record the response shape", + "confirm password-reuse prevention if configured: resetting to the OLD password is refused where password_history_count > 0 (previous_password_hashes ring, ADR-0069 D1)" + ], + "acceptance": [ + { + "clause": "the request issues a real reset artifact: POST /api/v1/auth/request-password-reset produces an email at the dev transport carrying a reset token/link (GET /api/v1/auth/reset-password/:token)", + "oracle": "log", + "verify": "the LogTransport output for the request contains the reset link with a token; the request response is a non-leaking 2xx/accepted", + "evidence": "the captured transport output" + }, + { + "clause": "the token completes the reset and rotates the credential: POST /api/v1/auth/reset-password with the token + new password succeeds, the new password signs in and the old one is refused", + "oracle": "api", + "verify": "reset 2xx; sign-in new 2xx; sign-in old non-2xx", + "evidence": "the reset response + the two sign-in attempts" + }, + { + "clause": "a consumed token cannot be reused: a second reset with the same token is refused with a named error", + "oracle": "api", + "verify": "the second reset-password returns non-2xx (token already used)", + "evidence": "the second-attempt response" + }, + { + "clause": "an expired or tampered token is refused LOUDLY — never a silent success that leaves the password unchanged while reporting OK", + "oracle": "api", + "verify": "reset with an expired/garbage token returns a named non-2xx and the credential is unchanged (old password still signs in)", + "evidence": "the refusal + a subsequent old-password sign-in" + }, + { + "clause": "request does not leak account existence: request-password-reset for an unknown email returns the same non-committal shape as for a known one (anti-enumeration)", + "oracle": "api", + "verify": "the known-email and unknown-email request responses are indistinguishable in status/body", + "evidence": "the two request responses" + } + ], + "negative": [ + "a reset that returns 2xx but leaves the password unchanged (token not actually honored) is a FAIL — the new password must sign in and the old must not", + "a reused or expired token that still resets the password is a security FAIL — tokens are single-use and time-bounded", + "request-password-reset returning a DIFFERENT response for known vs unknown emails is an account-enumeration FAIL", + "a silent success on a garbage token (no error, no change) is a FAIL — the spec's contract is a loud refusal" + ], + "traps": ["dispatcher-vs-hono-route", "wrong-persona"], + "source": [ + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (BETTER_AUTH_MOUNTED_SURFACE: POST /api/v1/auth/request-password-reset, POST /api/v1/auth/reset-password, GET /api/v1/auth/reset-password/:token — the LIVE routes; AuthEndpointPaths.forgetPassword='/forget-password' is the divergent spec name)", + "packages/plugins/plugin-email/src/transports/index.ts (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport — the dev capture transport)", + "packages/platform-objects/src/identity/sys-account.object.ts (previous_password_hashes ring — ADR-0069 D1 reuse-prevention)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: forgot-password round trip (request-password-reset → dev-transport token capture → reset-password), old-password-refused/new-works, single-use + expiry refusals, anti-enumeration, with the token-capture dev-mail dependency recorded as a knownGap (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.oauth-app-consent-loop", + "title": "OAuth provider: register an app (secret shown once), run the authorization-code consent loop — approve mints tokens + a consent record, deny mints none", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["an org admin registering the OAuth client", "the resource-owner user granting/denying consent"], + "fixtures": { + "app": "showcase", + "requires": [ + "the better-auth oidcProvider plugin configured (the oauth2/* routes are gated: auth-route-ledger 'requires: oidcProvider') — stock showcase ships no configured OAuth provider flow, so this item is blocked(fixture) until one is provisioned", + "a registered client + a redirect URI to complete the authorization-code round trip" + ], + "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)" + ] + }, + "blocked": { "by": "fixture", "ref": "no stock showcase oidcProvider flow — needs a configured OAuth provider + client, same fixture class as identity-auth.sso-enforced-first-paint / linked-accounts-social" }, + "steps": [ + "as admin, register an OAuth client: POST /api/v1/auth/oauth2/create-client (client oauth.applications.register, requires oidcProvider); capture the response and the client_secret — revealed ONCE at registration", + "re-read the client via GET /api/v1/auth/oauth2/get-client and confirm the secret is NOT returned again (only client_id / public metadata)", + "begin the authorization-code flow: GET /api/v1/auth/oauth2/authorize with the client_id, redirect_uri, scope and state; as the resource owner, land on the consent page and screenshot the requested-scopes list", + "APPROVE: POST /api/v1/auth/oauth2/consent (oauth.consent) accept; follow the redirect, exchange the code at POST /api/v1/auth/oauth2/token, and capture the issued access/refresh tokens", + "confirm a consent record now exists: GET /api/v1/auth/oauth2/get-consents shows a consent for this client covering the approved scopes (sys_oauth_consent row — not data-API readable)", + "run the flow again for the SAME client+scopes and confirm the consent screen is SKIPPED (the recorded consent short-circuits it)", + "DENY path: start a fresh authorize with an added scope (forcing consent), deny it, and confirm NO tokens are issued and NO new consent is recorded", + "mine-view scoping: confirm the consent surface shows the CALLER's consents only — another user cannot see this user's oauth2 consents" + ], + "acceptance": [ + { + "clause": "the client_secret is revealed exactly once at registration: create-client returns it, and get-client afterwards returns only public metadata (no secret)", + "oracle": "api", + "verify": "the create-client response carries client_secret; the follow-up get-client does not", + "evidence": "the create + get responses" + }, + { + "clause": "the consent page lists the requested scopes before any token is minted — the user sees what they are authorizing", + "oracle": "screenshot", + "verify": "the consent screen enumerates the scopes from the authorize request", + "evidence": "the consent screenshot" + }, + { + "clause": "approve mints tokens AND records consent: the token exchange returns access/refresh tokens and get-consents shows a consent covering the approved scopes for this client", + "oracle": "api", + "verify": "POST oauth2/token returns tokens; GET oauth2/get-consents lists the client+scopes (the sys_oauth_consent row's existence is the consent — no consent_given flag)", + "evidence": "the token response + the get-consents read" + }, + { + "clause": "a recorded consent short-circuits the screen: a repeat authorize for the same client+scopes skips consent and proceeds to the redirect", + "oracle": "network", + "verify": "the second authorize does not render the consent page; it redirects with a code directly", + "evidence": "the second-flow trace" + }, + { + "clause": "deny mints nothing: denying consent issues no tokens and records no consent — the deny path is a clean no-op on credentials", + "oracle": "api", + "verify": "no token is exchanged after deny; get-consents shows no new consent for the denied scope set", + "evidence": "the deny trace + the get-consents read" + }, + { + "clause": "consents are mine-view scoped: get-consents returns only the caller's own consents", + "oracle": "api", + "verify": "a second user's get-consents does not include this user's consent", + "evidence": "the two get-consents reads" + } + ], + "negative": [ + "the client_secret being retrievable after registration (via get-client or the data API) is a FAIL — it is a show-once credential", + "approve that mints tokens but records NO consent (so the screen re-prompts forever) — or deny that still mints tokens — is a FAIL", + "one user seeing another user's oauth2 consents is a scoping FAIL" + ], + "traps": ["dispatcher-vs-hono-route", "wrong-persona", "hydration-race"], + "source": [ + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (oauth-provider family, requires oidcProvider: oauth2/create-client=oauth.applications.register, get-client, consent=oauth.consent, get-consents, oauth2/authorize, oauth2/token)", + "packages/platform-objects/src/identity/sys-oauth-consent.object.ts (row implies consent for listed scopes — consent_given removed; apiEnabled:false so verify via get-consents, not the data API)", + "packages/platform-objects/src/identity/sys-oauth-application.object.ts + setup-nav.contributions.ts (nav_oauth_apps → Setup OAuth Applications)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: OAuth client registration (secret once) + authorization-code consent loop (approve mints tokens + consent record, deny mints none, recorded consent short-circuits), mine-view scoped; blocked(fixture) pending a configured oidcProvider flow (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.linked-accounts-social", + "title": "Linked accounts: link a social/OIDC identity through the redirect round-trip → a sys_account row appears in mine-view; unlink removes it; provider-less boot degrades honestly", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["a signed-in user linking a second identity", "the same user after unlinking"], + "fixtures": { + "app": "showcase", + "requires": [ + "at least one social/OIDC provider configured (socialProviders or oidcProviders) so link_social has a provider to dance with — stock showcase ships none, so this item is blocked(fixture) until a provider is configured", + "the Account 'Identity Links' surface (nav_accounts → sys_account) reachable" + ] + }, + "blocked": { "by": "fixture", "ref": "no stock showcase social/OIDC IdP — link_social needs a configured provider, same fixture class as identity-auth.sso-enforced-first-paint / oauth-app-consent-loop" }, + "steps": [ + "as a signed-in user, open Account → Identity Links (sys_account `mine` view, filter user_id={current_user_id}); screenshot the initial link set", + "invoke link_social for a configured provider: the action is type:'url' — GET /api/v1/auth/sign-in/social?provider=

&callbackURL=/_console/apps/account/sys_account (full-page navigation, NOT XHR, so the OAuth 302 dance and link cookie work); complete the provider round-trip and land back on the Identity Links view", + "confirm a sys_account row now exists for that provider in the caller's mine-view (provider_id = the provider, user_id = the caller, issuer stamped)", + "unlink it: the unlink_account action → POST /api/v1/auth/unlink-account with accountId = the sys_account ROW id (better-auth 1.7 keys on the row id); confirm the row is gone from mine-view", + "both-sides / degradation: on a boot with NO provider configured, confirm link_social degrades honestly — the affordance is absent or names the missing provider, rather than offering a link that dead-ends (open-edition honest-degradation posture)", + "confirm sys_account is read-only over the data API — a forged direct insert/delete is refused (apiMethods ['get','list'], writes 405)" + ], + "acceptance": [ + { + "clause": "linking a social identity creates a sys_account row in the caller's mine-view after the redirect round-trip completes", + "oracle": "api", + "verify": "GET /api/v1/data/sys_account (mine view) after the link shows a new row with provider_id = the provider and user_id = the caller; issuer is stamped", + "evidence": "the post-link mine-view read" + }, + { + "clause": "the link surface renders the caller's own links only (mine-view scoped) — screenshot-confirmed", + "oracle": "screenshot", + "verify": "Identity Links shows the caller's provider rows; a different user's links are not present", + "evidence": "the Identity Links screenshot" + }, + { + "clause": "unlink removes the row: unlink_account keyed on the sys_account row id deletes exactly that link from mine-view", + "oracle": "api", + "verify": "POST /api/v1/auth/unlink-account with the row's accountId; a follow-up mine-view read no longer contains it", + "evidence": "the unlink response + the follow-up read" + }, + { + "clause": "a provider-less boot degrades honestly: link_social is absent or names the missing provider — never a link affordance that dead-ends", + "oracle": "screenshot", + "verify": "with no provider configured, the Identity Links surface shows no dead link action (or an explicit unavailable state)", + "evidence": "the provider-less screenshot" + }, + { + "clause": "sys_account is read-only over the data API — links are mutated only through the auth endpoints, not raw row writes", + "oracle": "api", + "verify": "a forged POST/DELETE to /api/v1/data/sys_account returns 405 (apiMethods ['get','list'], identity write guard ADR-0092 D2)", + "evidence": "the forged-write response" + } + ], + "negative": [ + "a link that appears in the UI but does not create a sys_account row (client-only) is a FAIL — the row is the durable identity link", + "unlink that hides the row from the list but leaves the sys_account (so the provider still signs the user in) is a FAIL", + "one user's identity links appearing in another's mine-view is an RLS FAIL", + "a provider-less boot offering a link_social action that navigates to a dead endpoint is a dishonest-degradation FAIL" + ], + "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"], + "source": [ + "packages/platform-objects/src/identity/sys-account.object.ts (link_social type:'url' → /api/v1/auth/sign-in/social?provider=&callbackURL=; unlink_account → /api/v1/auth/unlink-account accountId=row id; mine view user_id={current_user_id} vs all_links; provider options; apiMethods ['get','list'])", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST link-social=auth.accounts.linkSocial, GET list-accounts=auth.accounts.list, POST unlink-account=auth.accounts.unlink)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_accounts → 'Identity Links', objectName sys_account)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: social/OIDC account linking round-trip → sys_account mine-view row, unlink removal, provider-less honest degradation; blocked(fixture) pending a configured IdP (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "identity-auth.identity-import-wizard", + "title": "Admin CSV identity import: password-policy auto/temporary drive per-row credentials, imported users sign in, non-admins denied", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["platform admin (running the import)", "an imported user (signing in afterwards)", "a non-admin (forger)"], + "fixtures": { + "app": "showcase", + "requires": [ + "platform-admin access to the identity Import Wizard (objectui app-shell identityImport.ts wraps the wizard onto POST /api/v1/auth/admin/import-users — the sys_user import cannot use the generic /data import because it must go through better-auth password hashing + credential creation)", + "a small scratch CSV of users (email required; the wizard parses it client-side into rows[])", + "for the `auto` invite-reachable path: an email/SMS transport so an invitation can be issued (dev `log` transport is sufficient to observe it); unreachable rows fall back to a one-time password" + ], + "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" + ] + }, + "steps": [ + "as admin, open the Import Wizard for Users, upload the scratch CSV, map email (and name/phone) — the wizard drives POST /api/v1/auth/admin/import-users in ≤500-row batches with the chosen passwordPolicy", + "run with passwordPolicy `auto` (the default, framework#3236): deliverable rows get an invitation (set-your-password email/SMS), unreachable rows fall back to a one-time password revealed once in the result step; capture the per-row result (action + any temporaryPassword) and screenshot the reveal", + "run a second import with passwordPolicy `temporary`: EVERY row gets a per-row one-time password (no invitations); confirm each result row carries a temporaryPassword shown once", + "sign in as an imported user: for a `temporary` (or auto-fallback) row use the revealed one-time password; for an `auto` invite row, follow the invitation to set a password; confirm email+password sign-in then works", + "idempotency: re-run the SAME CSV in upsert mode (matchBy email) and confirm it upserts (updates) rather than duplicating — the endpoint matches on email/phone", + "confirm one-time passwords are NOT persisted anywhere: they appear only in the import response/result step, never in a later read of the user or any audit row", + "both-sides gate: as a NON-admin, POST /api/v1/auth/admin/import-users directly and capture the refusal (the endpoint is platform-admin-gated)" + ], + "acceptance": [ + { + "clause": "policy `auto` splits per row: deliverable rows get an invitation, unreachable rows fall back to a one-time password revealed once — the wizard result surfaces both outcomes", + "oracle": "api", + "verify": "the import response's per-row results show action + (for fallback rows) a temporaryPassword; deliverable rows show an invitation outcome (observed at the dev transport)", + "evidence": "the import response + the transport capture + the reveal screenshot" + }, + { + "clause": "policy `temporary` forces a per-row one-time password for EVERY row (no invitations)", + "oracle": "api", + "verify": "every result row under `temporary` carries a temporaryPassword; no invitation is issued", + "evidence": "the import response" + }, + { + "clause": "an imported user can sign in: the one-time password (temporary/auto-fallback) or the invitation-set password authenticates via email+password", + "oracle": "api", + "verify": "POST /api/v1/auth/sign-in/email for an imported user with the revealed/one-time credential returns a session", + "evidence": "the sign-in trace" + }, + { + "clause": "re-import is idempotent on upsert: re-running the same CSV (matchBy email) updates existing users rather than creating duplicates", + "oracle": "api", + "verify": "sys_user count for the imported emails is unchanged after the second run; the summary shows updated/skipped, not created", + "evidence": "the two import summaries + the sys_user read" + }, + { + "clause": "one-time passwords are never persisted: they exist only in the import response/result step, absent from any later user read or audit row", + "oracle": "api", + "verify": "a follow-up read of an imported user (and any audit row) contains no plaintext temporary password", + "evidence": "the follow-up reads" + }, + { + "clause": "the import is platform-admin-gated: a non-admin's direct POST /api/v1/auth/admin/import-users is refused server-side", + "oracle": "api", + "verify": "the forged non-admin request returns non-2xx and no users are created", + "evidence": "the refusal + the unchanged sys_user read" + } + ], + "negative": [ + "a `temporary` import that leaves any row WITHOUT a one-time credential (so the user can never sign in) is a FAIL", + "a one-time password persisted anywhere server-side (user row, audit log) is a security FAIL — it is response-only", + "a re-import that DUPLICATES users instead of upserting on the match key is a FAIL", + "a non-admin succeeding at import-users is a privilege FAIL — apply RUNNER rule 7" + ], + "variants": [ + "auto (default: invite reachable, one-time-password fallback for unreachable)", + "temporary (per-row one-time password for every row)", + "invite (force an invitation for every row; unreachable rows fail)", + "none (identity only — user sets a password later via OTP / magic link / reset)" + ], + "traps": ["wrong-persona", "seed-data-thin"], + "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)" + ], + "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" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json new file mode 100644 index 0000000000..83bc2b0665 --- /dev/null +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -0,0 +1,1199 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md. FIXTURE MAP for this area (all in-repo, CI-deterministic): the showcase ships four declarative connectors (examples/app-showcase/src/system/connectors/index.ts — rest/openapi/mcp provider-bound instances + one enabled:false catalog descriptor), an outbound webhook (src/automation/webhooks — shipped inactive), a cron job (src/automation/jobs — showcase_health_sweep), an email template (src/system/emails — showcase_task_done_email) and two notify flows (src/automation/flows).", + "area": "integration-system", + "title": "Integration & system services — connectors, webhooks, jobs, email templates, notifications", + "items": [ + { + "id": "integration-system.connector-declarative-boot", + "title": "Provider-bound declarative connectors materialize at boot for every installed provider kind (rest/openapi/mcp), list with origin:'declarative' state:'ready', and dispatch end-to-end from connector_action", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "fixtures": { + "app": "showcase", + "requires": [ + "the three provider-bound showcase instances (examples/app-showcase/src/system/connectors/index.ts): showcase_status_api (provider:'rest', providerConfig.baseUrl self-pointing), showcase_status_openapi (provider:'openapi', file-path spec), showcase_mcp_tools (provider:'mcp', stdio fixture scripts/mcp-fixture.mjs) — #2994/#3062/#3056", + "the provider factories installed in objectstack.config.ts: ConnectorRestPlugin, ConnectorOpenApiPlugin, ConnectorMcpPlugin({ declarativeStdio: ['node'] })", + "the two dispatch flows: ShowcaseDeclarativeConnectorPingFlow (rest getHealth) and ShowcaseMcpConnectorEchoFlow (mcp echo_upper) — examples/app-showcase/src/automation/flows/index.ts" + ] + }, + "variants": [ + "provider: rest — providerConfig { baseUrl }, actions hand-derived by the factory", + "provider: openapi — providerConfig { spec, baseUrl? }; actions derived from the OpenAPI document's operations (getHealth)", + "provider: mcp — providerConfig { transport }; actions derived from the upstream's tools/list (echo_upper)", + "no provider — catalog descriptor (showcase_erp_catalog): registered as metadata only, NEVER in the runtime registry (#2612)" + ], + "steps": [ + "boot the showcase; capture the boot log lines 'Connector registered: (… origin: declarative)'", + "GET /api/v1/automation/connectors (route ledger: automation.listConnectors); the body is { connectors, total }", + "for each of the three instances, record its descriptor: origin, state, and the actions[] array (key/label/inputSchema/outputSchema/effect)", + "confirm showcase_erp_catalog is ABSENT from the runtime listing (it has no provider — descriptor only)", + "POST /api/v1/automation/showcase_declarative_connector_ping/trigger and .../showcase_mcp_connector_echo/trigger (automation.execute); read each run via GET /automation/:name/runs", + "author the DeclarativeConnectorEntrySchema negatives in a scratch package and build each: (a) providerConfig with NO provider, (b) provider-bound entry that also authors actions[], (c) provider-bound entry with inline authentication { type: 'api-key', … }, (d) provider: 'no_such_provider'; capture all four rejections", + "disable one declaration (enabled:false) and reboot; re-list" + ], + "acceptance": [ + { + "clause": "all three provider variants appear in GET /automation/connectors with origin:'declarative' and state:'ready' — one clause verdict PER variant, none inferred from a sibling", + "oracle": "api", + "verify": "the listing contains showcase_status_api, showcase_status_openapi, showcase_mcp_tools each with origin 'declarative' + state 'ready' (engine.getConnectorDescriptors shape)", + "evidence": "the three descriptors, keyed by provider variant" + }, + { + "clause": "actions are DERIVED from each provider's upstream, not authored: the mcp instance's action set equals the fixture server's tools/list (echo_upper), the openapi instance's equals the document's operations (getHealth)", + "oracle": "api", + "verify": "descriptor actions[] against scripts/mcp-fixture.mjs and src/system/connectors/status-openapi.json", + "evidence": "the action arrays next to their upstream sources" + }, + { + "clause": "a materialized instance is DISPATCHABLE, indistinguishable from a hand-registered connector: both flows complete and their connector_action outputs land in flow variables (rest ping returns the health payload; mcp echo returns the uppercased string)", + "oracle": "api", + "verify": "the two triggered runs succeed; run output/variables carry the upstream results", + "evidence": "the two run records" + }, + { + "clause": "the authoring gate rejects each malformed entry with its located ADR-0097 message: providerConfig-without-provider ('`providerConfig` requires a `provider` …'), instance-authored actions ('must not author `actions` — the … provider derives them from the upstream at boot'), inline secrets ('must not inline secrets via `authentication`; reference credentials with `auth: { type, credentialRef }`')", + "oracle": "build", + "verify": "the three superRefine rejections match packages/spec/src/integration/connector.zod.ts DeclarativeConnectorEntrySchema verbatim on the quoted fragments", + "evidence": "the three error texts" + }, + { + "clause": "an unknown provider key is a HARD BOOT ERROR (fail loudly, ADR-0097 §Decision) — never a silently-dead connector", + "oracle": "log", + "verify": "boot with provider:'no_such_provider' aborts with an error naming the provider", + "evidence": "the fatal boot output" + }, + { + "clause": "materialization tracks the metadata: a disabled/removed declaration is gone from the listing after reboot (and torn down on reload) — it does not fossilize", + "oracle": "api", + "verify": "post-disable listing omits the instance", + "evidence": "the before/after listings" + } + ], + "negative": [ + "the catalog descriptor (showcase_erp_catalog) appearing in the RUNTIME listing is a FAIL — descriptor vs instance is the #2612 boundary this whole surface is built on", + "any of the four malformed-entry builds passing clean is a FAIL (silent acceptance of an unmaterializable declaration)" + ], + "traps": [ + "stale-dist" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts" + }, + "source": [ + "docs/plans/release-15.1-test-plan.md §B1 (#2994/#3062)", + "packages/spec/src/integration/connector.zod.ts (provider/providerConfig/auth keys + DeclarativeConnectorEntrySchema cross-field rejections, exact messages)", + "packages/spec/src/integration/connector-provider.ts (factory contract; adopt-declared-name; throw ⇒ hard boot error)", + "packages/services/service-automation/src/engine.ts (registerConnector origin 'declarative'; getConnectorDescriptors shape)", + "packages/runtime/src/route-ledger.ts (GET /automation/connectors, POST /automation/:name/trigger, GET /automation/:name/runs)", + "examples/app-showcase/src/system/connectors/index.ts + src/automation/flows/index.ts (the fixtures)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the 15.1 plan §B — connectors had no checklist coverage", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.connector-degraded-recovery", + "title": "An unreachable connector upstream degrades that ONE instance (CONNECTOR_UPSTREAM_UNAVAILABLE) instead of failing boot, retries on 5s→300s backoff, and recovers atomically — while config faults stay boot-fatal", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase_mcp_tools stdio instance — break its upstream deterministically by pointing providerConfig.transport.args at a nonexistent script (the exact experiment its own fixture comment prescribes, examples/app-showcase/src/system/connectors/index.ts)", + "a config-fault twin for the both-sides check: the same entry with an invalid providerConfig shape (e.g. transport.kind: 'carrier_pigeon')" + ] + }, + "steps": [ + "boot with the broken upstream; time the boot and capture the '[Automation] connector … registered DEGRADED' warn with its degradedReason", + "GET /api/v1/automation/connectors; record the husk descriptor (state, degradedReason, actions)", + "POST the flow that dispatches it (showcase_mcp_connector_echo trigger); capture the dispatch failure", + "watch the log through at least three retry cycles; record the intervals", + "restore the upstream (fix the script path is a config CHANGE — instead restore by making the original path valid again, e.g. re-adding the file) WITHOUT restarting; wait one backoff cycle; re-read the descriptor", + "separately, boot the config-fault twin and capture that boot's outcome", + "trigger a metadata:reloaded reconcile (touch/save the entry) and confirm it retries immediately and resets the backoff" + ], + "acceptance": [ + { + "clause": "boot COMPLETES on the dead upstream — the instance lands as a degraded husk: descriptor state:'degraded' with a degradedReason quoting the operational failure; no actions, and it still appears in GET /automation/connectors (visible, not vanished)", + "oracle": "api", + "verify": "boot exit + the husk descriptor (engine.registerDegradedConnector: state 'degraded', empty handlers, stored degradedReason)", + "evidence": "boot log + descriptor" + }, + { + "clause": "the degrade path is taken ONLY for errors carrying code CONNECTOR_UPSTREAM_UNAVAILABLE (structural check, not instanceof); a configuration fault (invalid providerConfig) remains FATAL at boot — both sides of the #3017 classification", + "oracle": "log", + "verify": "the broken-upstream boot degrades; the config-fault twin's boot aborts with the factory's validation error (connector-provider-errors.ts contract)", + "evidence": "the two boot outcomes side by side" + }, + { + "clause": "dispatching a degraded instance fails FAST with a pointed connector-unavailable error that quotes the stored degradedReason — not a timeout hang, and distinguishable from 'no such connector/action'", + "oracle": "api", + "verify": "the connector_action step errors promptly; its message carries the degradedReason (engine.getConnectorDegradedReason feeds the refusal)", + "evidence": "the failed run record" + }, + { + "clause": "retries back off from DECLARATIVE_RETRY_BASE_MS (5s) doubling to the 300s ceiling — no hot loop against the dead upstream", + "oracle": "log", + "verify": "observed retry intervals ≈ 5s/10s/20s… (service-automation/src/plugin.ts constants); a config edit or reload reconcile resets the backoff and retries immediately", + "evidence": "timestamped log excerpt" + }, + { + "clause": "recovery is ATOMIC and restart-free: once the upstream is reachable a retry swaps the live instance in — descriptor flips to state:'ready' with the derived actions, and the previously-failing flow dispatch now succeeds", + "oracle": "api", + "verify": "post-recovery descriptor + a successful echo run, all without a server restart", + "evidence": "the recovered descriptor + run record" + } + ], + "negative": [ + "a dead upstream taking the WHOLE boot down is the outage-amplification #3017 exists to prevent — FAIL", + "a degraded instance silently disappearing from GET /automation/connectors is a FAIL (operators must be able to SEE the husk and its reason)" + ], + "traps": [ + "stale-dist" + ], + "source": [ + "docs/plans/release-15.1-test-plan.md §B3 (#3049)", + "packages/spec/src/integration/connector-provider-errors.ts (CONNECTOR_UPSTREAM_UNAVAILABLE marker; structural isConnectorUpstreamUnavailable)", + "packages/services/service-automation/src/plugin.ts (DECLARATIVE_RETRY_BASE_MS=5000 / DECLARATIVE_RETRY_MAX_MS=300000; degradedInstances backoff + reload reconcile; boot fatal vs reload skip)", + "packages/services/service-automation/src/engine.ts (registerDegradedConnector; getConnectorDegradedReason feeding the dispatch refusal)", + "packages/services/service-automation/src/connector-degrade-cause.test.ts + degraded-register-cause.test.ts (unit pins)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the 15.1 plan §B3", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.connector-stdio-default-deny", + "title": "Declarative stdio connector transports are denied by default; the host allowlists exact commands (declarativeStdio) — and the deny is a config fault (boot fatal), not a degrade", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "cli", + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase_mcp_tools stdio instance (command 'node') + the host opt-in `new ConnectorMcpPlugin({ declarativeStdio: ['node'] })` in examples/app-showcase/objectstack.config.ts — removing that option IS the deny fixture (the connector file's own comment says 'try it')" + ] + }, + "variants": [ + "policy absent/false → deny every declarative stdio transport (default)", + "policy ['node'] with command 'node' → allowed (strict string equality)", + "policy ['node'] with a non-matching command (e.g. 'python3') → denied, allowlist quoted", + "http transport → never subject to the stdio policy (control)" + ], + "steps": [ + "boot the stock showcase (allowlisted): confirm showcase_mcp_tools materializes state:'ready'", + "remove the declarativeStdio option from ConnectorMcpPlugin in objectstack.config.ts; boot; capture the failure verbatim", + "restore the option but change the connector's command to one NOT in the allowlist (e.g. 'python3' with the same script); boot; capture that failure", + "restore fully; author a scratch mcp instance with an HTTP transport pointing at any URL and confirm the stdio policy does not touch it (it may degrade on unreachability — that is the OTHER item)", + "GET /api/v1/automation/connectors after the allowlisted boot" + ], + "acceptance": [ + { + "clause": "the non-opted-in stdio instance refuses with the message naming the mechanism: 'declares a stdio transport (command \\'node\\'), but declarative stdio transports are disabled by default — a stdio transport launches a local process …' (connector-mcp provider, #3055)", + "oracle": "log", + "verify": "boot output contains the deny message from packages/connectors/connector-mcp/src/mcp-provider.ts; the fixture process is never spawned", + "evidence": "the refusal line" + }, + { + "clause": "an allowlist MISMATCH is refused quoting both the offending command and the configured allowlist: \"stdio transport with command 'python3', which is not in the host's declarativeStdio allowlist [node]\" — equality is strict, no prefix/glob creep", + "oracle": "log", + "verify": "the mismatch boot's error text carries command + allowlist", + "evidence": "the refusal line" + }, + { + "clause": "the deny is classified as a CONFIGURATION fault: boot-fatal at start (and a skipped entry on reload) — NOT a degraded husk retrying forever toward a process the host never authorized", + "oracle": "log", + "verify": "the denied boots abort; no 'registered DEGRADED' line and no retry loop for the denied instance", + "evidence": "boot output" + }, + { + "clause": "the allowlisted boot materializes the instance normally (state:'ready', echo_upper action derived) — the opt-in works and stays scoped to the exact command", + "oracle": "api", + "verify": "GET /automation/connectors shows showcase_mcp_tools ready on the stock config", + "evidence": "the descriptor" + } + ], + "negative": [ + "absence of ANY allowlist config silently spawning a subprocess from metadata is the security failure #3055 closed — a spawned fixture process on the deny boot is a P0-severity FAIL regardless of this item's priority", + "the deny surfacing as state:'degraded' with backoff retries is a FAIL — an unauthorized command must not be retried into existence" + ], + "automated": { + "kind": "unit", + "ref": "packages/connectors/connector-mcp/src/mcp-provider.test.ts" + }, + "source": [ + "docs/plans/release-15.1-test-plan.md §B4 (#3059)", + "packages/connectors/connector-mcp/src/mcp-provider.ts (#3055 policy: default deny, strict-equality allowlist, both messages)", + "examples/app-showcase/objectstack.config.ts (declarativeStdio: ['node'] opt-in) + src/system/connectors/index.ts (the 'remove that option and boot fails loudly — try it' fixture note)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the 15.1 plan §B4", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.connector-spec-path-no-escape", + "title": "Connector openapi spec file refs resolve package-relative only: './…' works, absolute and '../' escapes reject with the confinement error, missing files fail loudly", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "build", + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped file-path fixture: showcase_status_openapi with providerConfig.spec './src/system/connectors/status-openapi.json' (#3016) — the happy path is stock", + "scratch copies of that entry for the three rejection probes (absolute path, '../' escape, missing file)" + ] + }, + "variants": [ + "spec: inline OpenAPI document object (no file read)", + "spec: package-relative file path './…' (resolved against packageRoot, confined)", + "spec: http(s) URL (fetched; unreachability is the degrade item's territory)", + "rejection: absolute path (/etc/… or C:\\…)", + "rejection: '../'-escaping relative path", + "rejection: missing/unreadable file" + ], + "steps": [ + "boot the stock showcase; confirm showcase_status_openapi materialized with the spec-derived getHealth action", + "author scratch entries and boot each: spec '/etc/hostname' (absolute), spec '../../outside.json' (escape), spec './does-not-exist.json' (missing); capture each error verbatim", + "author one with a nested traversal that RESOLVES inside the root after normalization (e.g. './src/../src/system/connectors/status-openapi.json') and record whether it is accepted — the guard rejects on the RESOLVED path, not on the substring", + "verify boot-vs-reload policy: repeat the missing-file probe via a metadata reload and confirm it is skipped-with-log instead of fatal" + ], + "acceptance": [ + { + "clause": "the package-relative path resolves and the connector's actions derive from the document (getHealth on the descriptor; dispatch covered by connector-declarative-boot)", + "oracle": "api", + "verify": "descriptor carries the spec-derived action set on the stock boot", + "evidence": "descriptor read" + }, + { + "clause": "an absolute path is rejected with the exact guard: \"package file ref '

' is absolute — file refs must be relative to the declaring stack/package root.\" — including Windows drive-letter forms", + "oracle": "build", + "verify": "boot error text matches createPackageFileLoader (packages/services/service-automation/src/plugin.ts)", + "evidence": "the error" + }, + { + "clause": "a path escaping the root after RESOLUTION is rejected: \"package file ref '

' escapes the stack/package root — reads are confined to ''.\" — while an inside-resolving './a/../b' form passes (the check is on the resolved path)", + "oracle": "build", + "verify": "the '../' probe errors with the confinement text; the normalized-inside probe boots", + "evidence": "the two outcomes" + }, + { + "clause": "a missing/unreadable file fails LOUDLY with the resolved path in the message ('could not be read (resolved to …)') — boot-fatal at start, skipped-with-log on reload, per the ADR-0097 reconcile policy", + "oracle": "build", + "verify": "the missing-file boot aborts; the reload path logs + skips", + "evidence": "both captures" + } + ], + "negative": [ + "any read landing OUTSIDE the declaring package root is a security FAIL whatever the error text — the confinement, not the message, is the contract", + "a missing spec producing a silently-actionless connector (instead of the loud failure) is a FAIL" + ], + "source": [ + "docs/plans/release-15.1-test-plan.md §B5 (#3024)", + "packages/services/service-automation/src/plugin.ts (createPackageFileLoader — all three exact error strings, #3016)", + "packages/spec/src/integration/connector.zod.ts (providerConfig.spec three-form doc) + connector-provider.ts (loadPackageFile contract incl. host-without-fs behavior)", + "examples/app-showcase/src/system/connectors/index.ts (showcase_status_openapi — the shipped happy-path fixture)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the 15.1 plan §B5", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.connector-descriptor-audit", + "title": "Descriptor-only connector contracts are audited at boot: declared-with-actions-but-unregistered warns with names + remedy; enabled:false is the deliberate, quiet catalog opt-out", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "cli", + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_erp_catalog — the shipped enabled:false catalog descriptor WITH actions (get_invoice, post_journal_entry): the stock boot is the quiet side of this gate", + "a scratch copy of it with enabled:true (and no provider) for the loud side" + ] + }, + "steps": [ + "boot the stock showcase and grep the full boot log for the inert-connector audit warning — there must be NONE (the only actions-bearing descriptor is enabled:false)", + "add the scratch enabled:true descriptor (actions, no provider, no plugin registration); boot; capture the warning verbatim", + "add a same-named RUNTIME registration for it (any connector plugin) and boot again — the audit must go quiet because the name is now live", + "flip the scratch entry back to enabled:false; boot; confirm quiet" + ], + "acceptance": [ + { + "clause": "the loud side names names and prescribes the fix: '[Automation] N declarative connector(s) declare actions but are not registered in the connector registry — the connector_action node cannot dispatch them: . … Install/instantiate the matching connector plugin, or mark a deliberate catalog-only entry with `enabled: false` to silence this warning.' (PD#10: declared ≠ delivered surfaces loudly)", + "oracle": "log", + "verify": "the warning matches auditDeclaredConnectors (packages/services/service-automation/src/plugin.ts) and lists the scratch connector's name", + "evidence": "log excerpt" + }, + { + "clause": "all three quiet conditions are individually verified: (a) enabled:false descriptor — quiet; (b) descriptor with a same-name runtime registration — quiet; (c) provider-bound instances — never audited by this gate (they materialize instead)", + "oracle": "log", + "verify": "no audit warning on the stock boot, on the registered boot, or for the three provider-bound showcase instances", + "evidence": "the grepped boot logs per condition" + }, + { + "clause": "the audit re-runs on metadata reload, not only at boot — enabling the scratch descriptor at runtime surfaces the warning without a restart", + "oracle": "log", + "verify": "the warn appears after the reload reconcile (plugin.ts wires auditDeclaredConnectors on both paths)", + "evidence": "timestamped log" + } + ], + "negative": [ + "a stock showcase boot emitting the inert-connector warning is a FAIL — either the shipped catalog descriptor lost its enabled:false or the audit's silencing contract broke", + "the warning firing for a provider-bound instance is a FAIL (it is an instance declaration, not a descriptor — #2977/ADR-0097 carve-out in findInertDeclaredConnectors)" + ], + "automated": { + "kind": "unit", + "ref": "packages/services/service-automation/src/connector-descriptor-audit.test.ts" + }, + "source": [ + "docs/plans/release-15.1-test-plan.md §B6 (#2985)", + "packages/services/service-automation/src/plugin.ts (auditDeclaredConnectors — exact warning text; boot + reload wiring)", + "packages/spec/src/integration/connector.zod.ts (enabled:false = deliberate catalog descriptor, #2612)", + "examples/app-showcase/src/system/connectors/index.ts (showcase_erp_catalog — the shipped quiet fixture)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the 15.1 plan §B6", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.flow-connector-picker", + "title": "The flow designer's connector picker mirrors GET /automation/connectors: same instances, declarative ones annotated, actions and their input schemas offered per pick", + "since": "v15.1", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the stock showcase connector registry: three declarative instances + the plugin-registered rest/slack connectors from objectstack.config.ts (so the picker has BOTH origins to distinguish)" + ] + }, + "steps": [ + "before the browser: GET /api/v1/automation/connectors and record { connectors, total } — this is the ground truth the picker must mirror", + "in Studio, open a flow (e.g. showcase_declarative_connector_ping) and add/select a connector_action node", + "open the connector picker; screenshot; only AFTER the screenshot, read the DOM list", + "pick showcase_mcp_tools; record which actions are offered and screenshot the action + input form for echo_upper", + "pick a plugin-registered connector and compare its presentation with the declarative one's annotation" + ], + "acceptance": [ + { + "clause": "the picker lists exactly the instances the API reports — no extras (a picker inventing catalog descriptors would let authors wire undispatchable nodes), none missing", + "oracle": "screenshot", + "verify": "picker screenshot cross-checked name-by-name against the pre-captured listing", + "evidence": "screenshot + API read, diffed" + }, + { + "clause": "declarative instances carry their annotation (origin distinguishable from plugin-registered), per the descriptor's origin field the API serves", + "oracle": "screenshot", + "verify": "the three declarative entries are visually marked; the plugin rest/slack ones are not", + "evidence": "annotated screenshot" + }, + { + "clause": "picking an instance offers its DERIVED actions with their input schemas (echo_upper for the mcp instance) — the ADR-0022 descriptor pipeline reaches the designer end-to-end", + "oracle": "dom", + "verify": "action list + input fields match the descriptor's actions[].inputSchema, read only after the screenshot confirmed render", + "evidence": "screenshot + DOM excerpt" + } + ], + "negative": [ + "a picker entry for a connector the API does not list (or vice versa) is a FAIL — the network response is the authority, not the panel" + ], + "traps": [ + "stale-console-bundle", + "hydration-race", + "wrong-panel" + ], + "source": [ + "docs/plans/release-15.1-test-plan.md §B2 (objectui#2563)", + "packages/services/service-automation/src/engine.ts (getConnectorDescriptors — the designer-facing shape incl. origin + actions[].inputSchema, ADR-0022)", + "packages/runtime/src/route-ledger.ts (GET /automation/connectors)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the 15.1 plan §B2", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.webhook-lifecycle", + "title": "Outbound webhooks materialize (spec object→object_name, isActive→active), fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, reject retired trigger kinds, and never clobber admin edits", + "since": "v15", + "status": "active", + "revision": 3, + "priority": "P1", + "surface": "mixed", + "fixtures": { + "app": "showcase", + "requires": [ + "a reachable webhook receiver (local echo server on the run's own port range) — point the shipped showcase_task_changed row at it, or author a scratch webhook", + "the shipped fixture: showcase_task_changed (object showcase_task, triggers create/update/delete, isActive:false ON PURPOSE — flipping it active in Setup is part of the test, examples/app-showcase/src/automation/webhooks/index.ts)", + "a predicate multi-write path for the bulk variants (update/delete with multi:true on showcase_task)" + ] + }, + "variants": [ + "trigger: create (data.record.created → per-record payload with recordId)", + "trigger: update (data.record.updated)", + "trigger: delete (data.record.deleted)", + "trigger: bulk_update (aggregate data.records.updated → { object, matched }, NO recordId/body — #4639)", + "trigger: bulk_delete (aggregate data.records.deleted, same shape)", + "retired: undelete — no event source exists (#3196); parse-rejected", + "retired: api (manual fire) — no fire path exists (#3196); parse-rejected" + ], + "steps": [ + "boot the showcase; verify the materializer bridge: read sys_webhook over /api/v1/data and locate showcase_task_changed with object_name:'showcase_task', active:false, managed_by:'package', and the envelope in definition_json", + "in Setup → Integrations → Webhooks flip the row active and point url at the local receiver (an admin edit — it must stamp customized)", + "create, update, then delete a showcase_task; capture the three deliveries at the receiver (headers incl. the HMAC signature when a secret is set, body incl. recordId)", + "run a predicate multi-update and multi-delete (multi:true) matching several rows; capture the bulk deliveries and their { object, matched } shape", + "read sys_http_delivery over /api/v1/data: one row per delivery with status/attempts/lastStatusCode", + "kill the receiver and mutate again; re-read the delivery row through its retry/failure states", + "author a scratch webhook with triggers:['undelete'] and one with ['api']; build both and capture the parse errors", + "redeploy/reboot and confirm the admin-edited row survived re-seed (customized rows are never clobbered)" + ], + "acceptance": [ + { + "clause": "authoring is LIVE, not a no-op: the declared webhook materializes into the sys_webhook row the dispatcher reads, with the two documented remaps (object→object_name, isActive→active) and the envelope in definition_json (#3461/#3489)", + "oracle": "api", + "verify": "the sys_webhook row fields against bootstrap-declared-webhooks mapWebhookToRow", + "evidence": "the row read" + }, + { + "clause": "each per-record trigger variant delivers exactly its event: create/update/delete each produce one receiver hit whose payload names the event and carries the recordId — verified per-variant, none inferred", + "oracle": "network", + "verify": "the three captured requests, keyed by variant", + "evidence": "receiver logs" + }, + { + "clause": "the bulk pair delivers the AGGREGATE shape — { object, matched } with no recordId and no record body — and only to webhooks that opted into bulk_update/bulk_delete; per-record subscribers do NOT receive a fabricated per-record event for a predicate write (#4639/#4626)", + "oracle": "network", + "verify": "bulk delivery bodies + absence of per-record deliveries for the same predicate write on a create/update/delete-only subscription", + "evidence": "receiver logs for both subscriptions" + }, + { + "clause": "delivery mechanics honor the authored envelope: custom headers attached, HMAC signature present when secret is set, timeoutMs applied — and every attempt is durably observable as a sys_http_delivery row (status pending/in_flight/success/failed/dead, attempts, lastStatusCode)", + "oracle": "api", + "verify": "receiver-side headers + the outbox rows over the data API", + "evidence": "headers + delivery rows" + }, + { + "clause": "a retired trigger kind fails at parse with the enum rejection — WebhookTriggerType is exactly [create, update, delete, bulk_update, bulk_delete]; undelete/api never register silently dead (the #3196 enforce-or-remove gate the #3358 sweep verified)", + "oracle": "build", + "verify": "both scratch builds error on the trigger value", + "evidence": "the two build errors" + }, + { + "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)", + "evidence": "before/after row reads" + } + ], + "negative": [ + "an unreachable receiver must surface as failed/dead sys_http_delivery rows (and retry per the outbox schedule) — a dropped delivery with no durable trace is a FAIL", + "a stored row whose triggers contain an unknown value must be dropped LOUDLY by the dispatcher (the #3196 drift-guard warn: 'dead while looking armed in Setup') — silent armed-looking deadness is a FAIL", + "connector-attached `webhooks`/`triggers` (integration/connector.zod.ts) are NOT dispatched by anything (#3197, said in-schema) — a run must not tick them as live, and a delivery appearing from one would be a spec-contract FAIL" + ], + "traps": [ + "seed-data-thin", + "stale-dist" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts" + }, + "source": [ + "packages/spec/src/automation/webhook.zod.ts (WebhookTriggerType enum + why undelete/api are absent; materialization contract; strict shape #4001)", + "packages/spec/liveness/webhook.json (all 11 props live via the #3489 bridge; per-prop line refs)", + "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts + auto-enqueuer.ts (remaps; trigger→event mapping incl. the #4639 bulk pair; #3196 unknown-trigger warn; seed-not-clobber)", + "packages/services/service-messaging/src/http-outbox.ts (delivery statuses, attempts, redeliver contract) + plugin-webhooks/webhook-outbox-plugin.ts (sys_http_delivery nav)", + "examples/app-showcase/src/automation/webhooks/index.ts (the shipped inactive fixture and its activation story)", + "#3358 §9 (webhook undelete/api trigger removal gate)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — webhook kind had no checklist coverage", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 3, + "date": "2026-08-08", + "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly", + "ref": "claude/platform-test-checklist-ocwugl" + } + ], + "enumSource": { + "file": "packages/spec/src/automation/webhook.zod.ts", + "export": "WebhookTriggerType", + "expect": 5 + } + }, + { + "id": "integration-system.job-scheduled-run", + "title": "Job metadata schedules through every ScheduleSchema variant, executes with retry/timeout enforced, records sys_job/sys_job_run truth, and refuses the closed doors (id key, runtime create, missing handler)", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped cron fixture: showcase_health_sweep (schedule cron '0 1 * * *' UTC, handler 'sweepProjectHealth' registered in defineStack({ functions }), retryPolicy { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 }, timeout 300000 — examples/app-showcase/src/automation/jobs/index.ts; the handler-missing regression here was #4774/#4888)", + "scratch jobs in a writable package for the interval / once / failing / timing-out probes (near-term schedules so the run observes real executions)" + ] + }, + "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", + "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)" + ], + "steps": [ + "boot the showcase; capture the AppPlugin job registration path for showcase_health_sweep (bundle jobs → IJobService.schedule on kernel:ready)", + "read sys_job over /api/v1/data: the row is keyed by NAME (the adapter mints its own row id) with the schedule persisted", + "author three scratch jobs (interval ~5s, once at now+1min, and a cron) with observable side effects; boot; let each fire; read sys_job_run rows and sys_job.last_run_at/last_status/run_count", + "author a deliberately-throwing job with retryPolicy { maxRetries: 2, backoffMs: 1000, backoffMultiplier: 2 }; let it exhaust; capture run rows + failure_count", + "author a job whose handler sleeps past a small `timeout`; capture the run's status", + "author a job with enabled:false and one whose handler string names NO registered function; boot; capture how each is skipped", + "author a job carrying `id: 'x'` and one with a stray key; build both; capture the errors", + "attempt to create a job at runtime through the meta door and capture the refusal (allowRuntimeCreate:false, #4509)" + ], + "acceptance": [ + { + "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)", + "evidence": "run rows + side-effect reads, keyed by variant" + }, + { + "clause": "execution truth is durable and name-keyed: sys_job upserts by name; every execution writes a sys_job_run row and bumps last_run_at/last_status/run_count/failure_count", + "oracle": "api", + "verify": "the counters move with each observed run", + "evidence": "before/after row reads" + }, + { + "clause": "a failing job retries per the CONVERGED policy — delay = backoffMs × multiplier^(retry-1) up to maxRetries — then records status 'failed' with the error message; a policy-less job gets ONE attempt (maxRetries defaults to 0 since 17.0.0, #4661 — a run asserting 3 default retries is testing the old world)", + "oracle": "log", + "verify": "attempt count + spacing in the run rows/log for the throwing probe; the no-policy probe shows exactly one attempt", + "evidence": "run rows + timestamped log" + }, + { + "clause": "an over-`timeout` run is recorded with execution status 'timeout' (the in-flight handler is abandoned, not force-cancelled — as documented), and timeouts COUNT as failures for the retry loop (#3494)", + "oracle": "api", + "verify": "the timeout probe's run row status", + "evidence": "the run row" + }, + { + "clause": "the two skip doors are LOUD and distinct: enabled:false skips scheduling at registration; a handler naming no registered function skips with '[AppPlugin] job handler not found in bundle.functions — skipping' — the exact silent-no-op that let the showcase sweep never run for months (#4774/#4888)", + "oracle": "log", + "verify": "both skip lines present; neither job has sys_job_run rows", + "evidence": "log excerpts + absence of runs" + }, + { + "clause": "the closed doors refuse loudly: authoring `id` errors with the #4667 prescription ('`job.id` was removed … `name` IS the job's identity everywhere … os migrate meta --from 16'); a stray key errors naming the key (aliases steer cron/interval→schedule, fn→handler); runtime create is refused (allowRuntimeCreate:false — handler strings cannot resolve outside the compiled bundle, #4509)", + "oracle": "build", + "verify": "the parse errors + the runtime-create refusal", + "evidence": "the error texts" + } + ], + "negative": [ + "a declared job that never runs WITHOUT one of the two loud skip lines is a FAIL against the scheduler, not thin data — 'nothing reports this' was the actual #4774 bug", + "a runtime-created job row saving cleanly is a FAIL (the #4509 door is closed precisely because such a job could never run)" + ], + "traps": [ + "stale-dist", + "seed-data-thin" + ], + "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/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)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — job kind had no checklist coverage", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.email-template-render", + "title": "Email templates materialize to sys_email_template, resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, refuses anonymous, and 400s malformed input", + "since": "v15", + "status": "active", + "revision": 3, + "priority": "P2", + "surface": "mixed", + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped fixture: showcase_task_done_email (category workflow, subject '✅ Task done: {{title}}', variables title required / project optional — examples/app-showcase/src/system/emails/index.ts)", + "a capturable email channel (dev transport / log capture — no real SMTP needed)", + "a second-locale scratch copy (same name, locale zh-CN) for the i18n-bundle clause" + ], + "knownGaps": [ + "no stock example exercises an email template end-to-end; needs a small fixture flow with a notify/email step", + "refinement of the gap above (2026-08-07 survey): the TEMPLATE fixture now exists (showcase_task_done_email) and the send path is pinned by packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts, but no shipped FLOW invokes sendTemplate — the flow-driven half still needs the fixture flow, or the send must be driven directly via IEmailService.sendTemplate in the run" + ] + }, + "variants": [ + "locale resolution: exact (name, locale) match / en-US fallback / two-locale bundle picking by recipient locale", + "body: bodyHtml + bodyText authored / bodyText omitted (service derives text from HTML — omitted from the ROW, not nulled)", + "gate: active:false → TEMPLATE_INACTIVE", + "gate: missing required variable → fail-fast (requireVars)", + "rejection: unknown key (strictObject; aliases title→subject, content/html→bodyHtml, text→bodyText, from/sender→fromOverride — #5013)", + "rejection: name not dotted snake_case" + ], + "steps": [ + "boot the showcase; read sys_email_template over /api/v1/data and locate showcase_task_done_email: bodyHtml→body_html remap, variables in variables_json, managed_by:'package'", + "drive a send with full data ({ title, project }) through IEmailService.sendTemplate (or the fixture flow once it exists); capture the rendered subject/body at the dev transport", + "drive a send MISSING the required `title`; capture the fast failure", + "set the row inactive (or author active:false) and send; capture the TEMPLATE_INACTIVE error", + "author the zh-CN twin; send to a zh-CN-locale recipient and to an unmatched-locale recipient; capture which row rendered each", + "author a template with fromOverride + replyTo and verify both on the outbound message", + "edit the template wording as an admin in Studio (stamps customized), redeploy/reboot, and re-read the row", + "edit the DECLARED source template and metadata-reload WITHOUT a reboot — the single item re-materializes (email_template is allowRuntimeCreate:true; the plugin subscribes to metadata changes)", + "build the two rejection probes (stray key `body`; name 'BadName') and capture the errors", + "drive the raw send door POST /api/v1/email/send three ways: (a) authed with a well-formed message { to, subject, bodyHtml } → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope" + ], + "acceptance": [ + { + "clause": "the authored template is what actually renders: subject/body substitute every {{path}} hole with the run's values, and the AUTHORED wording (not a built-in or stale copy) reaches the transport — the exact three-break disconnect #4509 closed (engine registration + managed_by stamping + bridge write)", + "oracle": "log", + "verify": "captured render contains the authored strings with substituted values and zero unresolved {{placeholder}} residue", + "evidence": "the captured render" + }, + { + "clause": "a send missing a REQUIRED variable fails fast with a located error naming the variable (requireVars, email-service.ts) — never a silently-empty substitution; the optional variable's absence does not fail the send", + "oracle": "log", + "verify": "the missing-title send errors; a missing-project send renders", + "evidence": "the two outcomes" + }, + { + "clause": "active:false is a real gate: sendTemplate returns TEMPLATE_INACTIVE (also the withdrawal mechanism — deleting a declared template deactivates rows rather than destroying them)", + "oracle": "log", + "verify": "the inactive send's error code", + "evidence": "the error" + }, + { + "clause": "(name, locale) resolution picks the best locale row and falls back to en-US — two rows with one name are an i18n bundle, both reachable by recipient locale", + "oracle": "log", + "verify": "the zh-CN recipient gets the zh-CN render; the unmatched recipient gets the fallback", + "evidence": "the two renders" + }, + { + "clause": "fromOverride and replyTo are honored on the outbound message; an omitted bodyText is derived from HTML at send time (and stays absent on the row so re-seeds never blank it)", + "oracle": "log", + "verify": "outbound headers + the derived text alternative", + "evidence": "the captured message" + }, + { + "clause": "both provenance protections hold: an admin-edited (customized) row survives redeploys, AND a declared-source edit re-materializes the single item on metadata reload without a restart", + "oracle": "api", + "verify": "post-reboot row keeps the admin wording; the reload path updates the untouched declared row", + "evidence": "before/after row reads for both paths" + }, + { + "clause": "authoring rejections are loud and prescriptive: a stray key errors naming it with the alias prescription (content→bodyHtml per #5013 — landing on the REQUIRED body so the rename renders); a non-dotted-snake-case name errors at parse", + "oracle": "build", + "verify": "the two probe errors against EmailTemplateDefinitionSchema", + "evidence": "the error texts" + }, + { + "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 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)", + "evidence": "the three responses + the dev-transport landing" + } + ], + "negative": [ + "the false-compliance case #4509 named is the standing FAIL: an admin 'fixes' a template and recipients keep receiving the old copy — any render not matching the authoritative row is a FAIL against the bridge", + "a send with an unresolved {{placeholder}} residue delivered to the transport is a FAIL (render must substitute or refuse, never ship holes)" + ], + "traps": [ + "stale-dist" + ], + "automated": { + "kind": "e2e", + "ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts" + }, + "source": [ + "packages/spec/src/system/email-template.zod.ts (requireds, dotted-name regex, aliases #5013, variables/required, fromOverride/replyTo/active)", + "packages/spec/liveness/email_template.json (the whole surface live via #4509; per-prop evidence incl. requireVars, TEMPLATE_INACTIVE, seed-not-clobber, runtime re-materialization)", + "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts + email-service.ts (row mapping; (name,locale)+fallback; the gates)", + "examples/app-showcase/src/system/emails/index.ts (showcase_task_done_email fixture)", + "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)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — email_template kind had no checklist coverage", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-07", + "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 3, + "date": "2026-08-08", + "change": "added the raw POST /api/v1/email/send route clause (authed → dev transport, anonymous → 401 UNAUTHENTICATED, malformed → 400 envelope, no-provider → 501) per PENDING-GAPS §D", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.notify-inbox-delivery", + "title": "The flow notify node delivers to the recipient's inbox (sys_inbox_message + receipt), readable and markable over /notifications, recipient-scoped — with unimplemented channels dead-lettering, never faking delivery", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "admin (to reassign the task)", + "the assignee member (to read the inbox)", + "a third member (for the scoping negative)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped fixture flow showcase_task_assigned_notify (notify node: topic 'task.assigned', recipients ['{record.assignee}'], channels ['inbox'], title/message/actionUrl — examples/app-showcase/src/automation/flows/index.ts)", + "MessagingServicePlugin installed (the showcase config requires the 'messaging' capability)", + "three personas with sessions: admin, the assignee, an unrelated member" + ], + "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" + ] + }, + "variants": [ + "channel: inbox — implemented, always-on (this item's happy path)", + "channel: email — implemented via plugin-email (covered by email-template-render; not re-proven here)", + "channel: sms — implemented via service-sms (needs provider credentials; blocked on stock fixtures)", + "channels push/slack/teams/webhook — unimplemented, dead-letter (#3197): negative-only" + ], + "steps": [ + "as admin, reassign a showcase_task to the assignee persona (assignee != previous.assignee fires the flow trigger)", + "as the ASSIGNEE, GET /api/v1/notifications (route ledger: notifications.list); locate the new row (title 'New task assigned: ', the actionUrl deep link)", + "as the THIRD member, GET /api/v1/notifications and confirm the notification is absent", + "unauthenticated GET /api/v1/notifications; capture the 401", + "as the assignee, POST /api/v1/notifications/read with { ids: [<id>] }; re-list with ?read=false and confirm it dropped out; then POST /notifications/read/all", + "read the backing rows over the data API (sys_inbox_message + sys_notification_receipt keyed (notification_id, user_id, channel:'inbox')) to confirm the surface reflects storage", + "author a scratch flow copy whose notify node names channels ['push']; run it; capture where the message lands (dead-letter), and that NO inbox row was fabricated" + ], + "acceptance": [ + { + "clause": "the notify node delivers: one inbox row for the assignee carrying the flow's title/message/actionUrl, listed over GET /notifications for that user", + "oracle": "api", + "verify": "the listing contains the notification with the authored strings after the reassignment", + "evidence": "the listing response" + }, + { + "clause": "delivery is recipient-scoped — the third member's listing does NOT contain it (server-side scoping, not client filtering)", + "oracle": "api", + "verify": "the third member's authenticated listing omits the row", + "evidence": "both listings side by side" + }, + { + "clause": "mark-read is persisted per (notification, user, channel): POST /notifications/read flips the receipt; an unread-filtered re-list omits it; read/all clears the rest — and the receipts are real rows, not client state", + "oracle": "api", + "verify": "the receipt row exists after markRead; the ?read=false listing shrinks accordingly", + "evidence": "the mutation responses + receipt row read" + }, + { + "clause": "the surface gates correctly on BOTH sides: unauthenticated → 401; with the messaging service absent/unserveable the route answers the 501 capability-unavailable envelope (never a fabricated empty inbox from a stub)", + "oracle": "api", + "verify": "the anonymous 401; the 501 side may be cited from a minimal boot without the messaging plugin", + "evidence": "the two responses" + }, + { + "clause": "an unimplemented channel dead-letters instead of faking success: the ['push'] probe produces NO inbox row and the dispatcher records the dead-letter — declared-but-unimplemented channels must stay visible failures (#3197)", + "oracle": "log", + "verify": "no sys_inbox_message row for the probe run; the dead-letter/log evidence captured", + "evidence": "absence check + log excerpt" + } + ], + "negative": [ + "a notify step reporting success while the recipient's listing stays empty is a FAIL against the delivery chain (flow → messaging → inbox row → route), wherever it broke", + "the third member seeing another user's notification is a security FAIL (receipts and rows are per-user; the route must scope server-side)" + ], + "traps": [ + "wrong-persona", + "seed-data-thin" + ], + "source": [ + "packages/runtime/src/domains/notifications.ts (routes, auth:true, sys_inbox_message + sys_notification_receipt join, receipt key)", + "packages/runtime/src/route-ledger.ts (GET /notifications, POST /notifications/read, POST /notifications/read/all)", + "packages/spec/src/system/notification.zod.ts (channel enum + the #3197 implemented-vs-dead-letter note and 'in-app' vs 'inbox' drift)", + "packages/services/service-messaging/src/inbox-channel.ts + channel.ts (inbox delivery writes; channels default ['inbox'])", + "examples/app-showcase/src/automation/flows/index.ts (showcase_task_assigned_notify — the worked ADR-0012 notify fixture)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "new item: the notify→inbox→/notifications chain had no checklist coverage; unimplemented channels pinned as dead-letter negatives per #3197 instead of asserted as capabilities", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.external-datasource-federated-read", + "title": "A declared external datasource federates: its objects query in place over REST, read-only", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped read-only SQLite external datasource (examples/app-showcase/src/system/datasources/ — showcase-external.datasource.ts + external-fixture.ts, External Customer / External Order)" + ] + }, + "steps": [ + "boot showcase isolated; sign in as admin", + "GET /api/v1/meta and confirm the federated objects are registered", + "GET /api/v1/data/<external object> and read fixture rows", + "attempt a write (POST/PATCH) against the read-only external object; capture the refusal", + "open Setup → Datasources and confirm the connection is listed with its health" + ], + "acceptance": [ + { + "clause": "the external datasource's objects appear in /meta and return the fixture rows over the normal /data query path — federation is transparent to the query layer", + "oracle": "api", + "verify": "/data/<external object> returns the seeded external rows; filters/$top work as on a native object", + "evidence": "the reads" + }, + { + "clause": "writes to a read-only external object are refused with a located error, not silently dropped or half-applied", + "oracle": "api", + "verify": "POST/PATCH → 4xx naming the read-only datasource", + "evidence": "the refusal" + }, + { + "clause": "the connection is visible and health-badged in Setup → Datasources", + "oracle": "screenshot", + "verify": "the datasources admin page shows the external connection", + "evidence": "screenshot" + } + ], + "negative": [ + "a write that appears to succeed against a read-only external source is a FAIL" + ], + "traps": [ + "stale-dist" + ], + "source": [ + "examples/app-showcase/src/system/datasources/ (showcase-external.datasource.ts, external-fixture.ts)", + "content/docs/capabilities/integrations.mdx (federated datasource claim)", + "packages/runtime route-ledger external-datasource family" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — the coverage.json `datasource` waiver was STALE (showcase ships a read-only SQLite external fixture); un-waived. NOTE: the external-datasource ADMIN CRUD lifecycle is a separate item (datasource-admin-lifecycle)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.datasource-admin-lifecycle", + "title": "The /api/v1/datasources admin lifecycle: static driver catalog, runtime create with provenance+health, secret never echoes (hasSecret only), bad drafts 400 DATASOURCE_ADMIN_ERROR, unwired federation degrades 503 naming external-datasource", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "os dev mounts the datasource admin routes at /api/v1/datasources by default (packages/cli/src/commands/serve.ts wires DatasourceAdminServicePlugin + registerDatasourceAdminRoutes when the engine is not 'memory')", + "the sqlite driver from the static catalog (packages/services/service-datasource/src/driver-catalog.ts) + a writable location for the sqlite file — the runtime-create probe target", + "a crypto provider / secret binder (createDatasourceSecretBinder) so the inline secret is bound, not stored cleartext" + ], + "knownGaps": [ + "the external-datasource FEDERATION service is intentionally NOT wired in the admin-lifecycle boot — its absence is what the 503-naming clause verifies; a boot that DOES wire it should record that and skip the 503 clause as not-applicable-this-run" + ] + }, + "steps": [ + "boot showcase via os dev (serve.ts mounts /api/v1/datasources by default on a non-memory engine); sign in as admin", + "GET /api/v1/datasources/drivers; confirm the static catalog (memory/sqlite/postgres/mysql/mongo, each with a configSchema) — this route has NO service dependency and answers even before any datasource-admin service is wired", + "POST /api/v1/datasources with a sqlite-file draft { name: 'qa_ds_probe', driver: 'sqlite', config: { file: '<writable path>' }, 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", + "GET /api/v1/datasources/qa_ds_probe/remote-tables (an external-datasource-served route) on the boot with federation UNWIRED; capture the 503 + which service its message names", + "GET /api/v1/datasources/does-not-exist; capture the 404" + ], + "acceptance": [ + { + "clause": "the driver catalog is static and always-available: GET /api/v1/datasources/drivers returns the curated driver set (memory/sqlite/postgres/mysql/mongo) each with a projected configSchema, with NO datasource-admin service dependency", + "oracle": "api", + "verify": "the drivers body against DRIVER_CATALOG (driver-catalog.ts); route answers even when the admin service is unwired", + "evidence": "the drivers response" + }, + { + "clause": "a runtime create lands with provenance + health: POST /api/v1/datasources creates a sqlite-file datasource (201) and the subsequent list shows it with origin:'runtime' and a health status", + "oracle": "api", + "verify": "POST status 201; GET /api/v1/datasources contains qa_ds_probe with origin 'runtime' + a health field", + "evidence": "the create + list responses" + }, + { + "clause": "the secret NEVER echoes back: the create body's inline secret is split out server-side (splitSecret) so it never reaches the persisted draft, and every read (getDatasource is credential-stripped) exposes only a hasSecret boolean plus non-sensitive config — the cleartext secret is returned by no GET", + "oracle": "api", + "verify": "GET /api/v1/datasources/qa_ds_probe carries hasSecret + config but no secret value; a grep of the list + detail bodies finds the secret nowhere", + "evidence": "the detail + list bodies (secret-absent)" + }, + { + "clause": "a malformed draft is refused 400 DATASOURCE_ADMIN_ERROR — the datasource-admin service's registered refusal code (attributed to the service that refused, #4249), never a 500", + "oracle": "api", + "verify": "the bad-draft POST: status 400, error.code DATASOURCE_ADMIN_ERROR (registered under @objectstack/service-datasource)", + "evidence": "the 400 response" + }, + { + "clause": "an unwired federation service degrades 503 naming external-datasource: the introspection routes (/:name/remote-tables, /:name/test, /:name/object-draft) answer 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)", + "oracle": "api", + "verify": "the remote-tables 503 message names 'external-datasource', not 'datasource-admin'", + "evidence": "the 503 response" + }, + { + "clause": "unknown name → 404 RESOURCE_NOT_FOUND; and (FINDING) the /api/v1/datasources admin CRUD is UNLEDGERED — absent from packages/rest/src/rest-route-ledger.ts (only the /datasources/:name/external/* federation routes are ledgered there), a tranche-3 route-ledger discipline gap the run must record (PENDING-GAPS §E)", + "oracle": "api", + "verify": "GET /api/v1/datasources/does-not-exist → 404 RESOURCE_NOT_FOUND; run record notes the admin routes carry no route-ledger entry", + "evidence": "the 404 + the unledgered-mount finding" + } + ], + "negative": [ + "the cleartext secret appearing in ANY list/detail response is a security FAIL — hasSecret is the only permitted signal", + "a federation-route 503 that names datasource-admin (the service that IS running) instead of external-datasource is the #4225 mis-attribution regressed — FAIL", + "a datasource-admin refusal carrying EXTERNAL_DATASOURCE_ERROR (or an external-datasource refusal carrying DATASOURCE_ADMIN_ERROR) is the #4249 code mis-attribution — FAIL" + ], + "traps": [ + "dispatcher-vs-hono-route", + "stale-dist" + ], + "automated": { + "kind": "unit", + "ref": "packages/services/service-datasource/src/__tests__/admin-routes.test.ts (+ __tests__/envelope.conformance.test.ts) — pins route behavior + envelope; the LIVE-mount half is not pinned, drive os dev for it" + }, + "source": [ + "packages/services/service-datasource/src/admin-routes.ts (the nine routes; splitSecret keeps the secret out of the persisted draft; resolve()/badRequest() per-service attribution #4225/#4249; getDatasource credential-stripped + hasSecret)", + "packages/services/service-datasource/src/driver-catalog.ts (static DRIVER_CATALOG, configSchema projected from spec #4410)", + "packages/spec/src/api/error-code-ledger.zod.ts (DATASOURCE_ADMIN_ERROR, EXTERNAL_DATASOURCE_ERROR under @objectstack/service-datasource)", + "packages/spec/src/api/errors.zod.ts (HttpStatusErrorCodeMap: 503 SERVICE_UNAVAILABLE, 404 RESOURCE_NOT_FOUND)", + "packages/cli/src/commands/serve.ts (mounts registerDatasourceAdminRoutes at /api/v1/datasources by default — NOT in the REST route ledger, tranche-3 gap)", + "PENDING-GAPS §B/§E (#4225/#4249; service-datasource has no route ledger)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — the external-datasource ADMIN lifecycle (driver catalog, runtime create + provenance/health, secret-never-echoes, 400/503 per-service attribution, unledgered-mount finding); distinct from external-datasource-federated-read (which tests the seeded read-only fixture's query path) — cross-referenced, not duplicated. Per PENDING-GAPS §B/§E (#4225/#4249)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "integration-system.notification-preference-suppression", + "title": "A recipient preference muting the inbox channel for a topic suppresses delivery (no inbox row, no dead-letter fake); flipping it back resumes; a sys_notification_template renders through a delivery", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin (to fire the notify flow by reassigning the task)", + "the assignee member (whose inbox is checked)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "MessagingServicePlugin installed (the showcase config requires the 'messaging' capability) — its emit() consults the PreferenceResolver before fan-out", + "the shipped notify flow showcase_task_assigned_notify (notify node: topic 'task.assigned', channels ['inbox'] — examples/app-showcase/src/automation/flows/index.ts)", + "two personas with sessions: admin and the assignee" + ], + "knownGaps": [ + "no shipped sys_notification_preference rows nor a sys_notification_template fixture — both authored in-run (both are writable system-data objects); the notify flow fixture itself is shipped. The spec's channel enum spells this 'in-app'; the implemented channel id is 'inbox' (the #3197 naming drift) — the preference row must name the IMPLEMENTED id 'inbox'" + ] + }, + "steps": [ + "boot showcase isolated; personas admin + assignee; establish the happy path first (or lean on notify-inbox-delivery): reassign a showcase_task to the assignee → showcase_task_assigned_notify fires topic 'task.assigned' on channel 'inbox' → one sys_inbox_message for the assignee, visible in GET /api/v1/notifications", + "author a suppression row via POST /api/v1/data/sys_notification_preference: { user_id: <assigneeId>, topic: 'task.assigned', channel: 'inbox', enabled: false } — this is the 'category' the task names; the model's field is `topic` (most-specific-wins over the built-in default-ON)", + "re-fire the notify flow (reassign the task to the same assignee again); GET /api/v1/notifications as the assignee and confirm NO new inbox row landed", + "read the messaging outbox / dead-letter surface and confirm there is no dead-letter row for the muted (recipient, 'inbox') — a deliberate mute is a DROP by the preference filter, not a dead-letter (dead-letter is reserved for unimplemented channels, #3197)", + "flip the row enabled:true (or delete it); re-fire; confirm the inbox row is delivered again", + "probe the resolution order: a user_id='*' admin-global default OFF with a user-specific row ON delivers (user row overrides global); a mandatoryTopics-configured topic bypasses the matrix entirely (cannot be muted)", + "render a sys_notification_template through a delivery: author a scratch sys_notification_template keyed (topic, channel, locale) and drive a template-backed channel (email/sms — messaging-service-plugin registers them as 'renders sys_notification_template'); capture the rendered subject/body" + ], + "acceptance": [ + { + "clause": "a suppression row mutes the channel: with (assignee, 'task.assigned', 'inbox', enabled:false) present, firing the notify flow delivers NO inbox row to the assignee — the PreferenceResolver drops the recipient once no channel survives the filter (most-specific-wins; built-in default ON)", + "oracle": "api", + "verify": "post-fire GET /api/v1/notifications for the assignee lacks the new row; the preference row is present with enabled:false", + "evidence": "the empty-of-new listing + the preference row" + }, + { + "clause": "no dead-letter fake: the muted delivery is DROPPED by preference, not recorded as a dead-letter/failed row — dead-letter is reserved for unimplemented channels (#3197); a deliberate mute must leave no fake failure trace", + "oracle": "api", + "verify": "no sys_inbox_message for the muted fire AND no dead-letter row for the (recipient, 'inbox') pair", + "evidence": "the absence checks (inbox + dead-letter)" + }, + { + "clause": "flipping back resumes delivery: setting enabled:true (or removing the row) restores default-ON and the next fire delivers the inbox row again — the mute is reversible and reflects the CURRENT matrix, not a cached decision", + "oracle": "api", + "verify": "post-flip GET /api/v1/notifications shows the new inbox row", + "evidence": "the before/after listings around the flip" + }, + { + "clause": "resolution is most-specific-wins: a user-specific row overrides the user_id='*' admin-global default, and a mandatoryTopics-configured topic bypasses the matrix (cannot be muted) — the ADR-0030 Layer-3 precedence holds on the live pipeline", + "oracle": "api", + "verify": "the global-OFF + user-ON probe delivers; a mandatory-topic fire delivers despite an enabled:false row", + "evidence": "the two probe outcomes" + }, + { + "clause": "a sys_notification_template renders through a delivery: driving a template-backed channel resolves (topic, channel, locale) from sys_notification_template with locale fallback and renders the subject/body holes — proving the template surface is live and DISTINCT from the inline title/message the inbox flow uses", + "oracle": "log", + "verify": "the captured render carries the template's substituted subject/body (email/sms channel path; messaging-service-plugin 'renders sys_notification_template')", + "evidence": "the captured render" + } + ], + "negative": [ + "a suppression row present and enabled:false but an inbox row STILL landing is a FAIL — the preference filter was bypassed (distinct from the by-design fail-OPEN, which applies only to a preference OUTAGE: no data engine / lookup error keeps all channels; a HEALTHY lookup ignoring an enabled:false row is the FAIL)", + "distinct from notify-inbox-delivery (the inbox happy path) — a run must not double-count the happy path here; this item's proof is the SUPPRESSION and its reversal" + ], + "traps": [ + "wrong-persona", + "seed-data-thin", + "stale-dist" + ], + "source": [ + "packages/services/service-messaging/src/objects/notification-preference.object.ts (sys_notification_preference: user_id × topic × channel × enabled; '*' wildcards + admin-global default; unique (user_id,topic,channel) index)", + "packages/services/service-messaging/src/preference-resolver.ts (PreferenceResolver.filter — most-specific-wins, mandatory-topic bypass, fail-open; drops recipients left with no accepted channel)", + "packages/services/service-messaging/src/messaging-service.ts (emit() consults the PreferenceResolver before fan-out)", + "packages/services/service-messaging/src/messaging-service-plugin.ts (email/sms channels render sys_notification_template; shared retry/dead-letter substrate)", + "packages/spec/src/system/notification.zod.ts (#3197 dead-letter for unimplemented channels; 'in-app' vs 'inbox' naming drift)", + "examples/app-showcase/src/automation/flows/index.ts (showcase_task_assigned_notify — topic 'task.assigned', channels ['inbox'])" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "new — preference-driven inbox suppression (no row, no dead-letter fake), reversal, ADR-0030 Layer-3 precedence, and a sys_notification_template render; distinct from notify-inbox-delivery (happy path). Per PENDING-GAPS §C", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json new file mode 100644 index 0000000000..8a9ef2f1a2 --- /dev/null +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -0,0 +1,1020 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "platform-core", + "title": "Platform core — boot, health, console shell, metadata pipeline", + "items": [ + { + "id": "platform-core.boot-health", + "title": "Showcase boots clean: health + ready 200, no degraded startup banners, console + app metadata served", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P0", + "surface": "mixed", + "preconditions": [ + "isolated run per dogfood-verification §0: own free port (not 3000/3001/3210, checked with lsof), own file DB (--seed-admin -d file:/tmp/<run>/data.db)" + ], + "steps": [ + "boot examples/app-showcase via `objectstack dev --ui --seed-admin -p <port> -d file:/tmp/<run>/data.db`", + "poll GET http://localhost:<port>/api/v1/health until 200; record time-to-healthy", + "GET /api/v1/ready and record the status (the readiness probe is a separate route — packages/runtime/src/route-ledger.ts)", + "read the FULL boot log: capture the Flows: banner, any ⚠ lines, any ERROR-level lines, and every SeedLoader line", + "GET /_console/ and confirm the console shell HTML is served (200, text/html)", + "GET /api/v1/meta/app?id=com.example.showcase and capture the merged app/nav metadata", + "cross-check the nav payload: the grp_data group lists the seeded objects (showcase_project, showcase_task, showcase_account, showcase_contact, showcase_invoice, showcase_field_zoo, …)" + ], + "acceptance": [ + { + "clause": "GET /api/v1/health returns 200 within the boot window", + "oracle": "api", + "verify": "curl -s -o /dev/null -w '%{http_code}' http://localhost:<port>/api/v1/health → 200", + "evidence": "the curl output + time-to-healthy" + }, + { + "clause": "GET /api/v1/ready returns 200 once boot completes (readiness, not just liveness)", + "oracle": "api", + "verify": "curl the /ready probe after health goes green", + "evidence": "the curl output" + }, + { + "clause": "the `Flows:` startup banner reports no ⚠ misauthored flows and no ERROR-level lines appear anywhere in the boot log", + "oracle": "log", + "verify": "grep the boot log for '⚠' in the Flows banner and for ERROR lines; seed rejections count as failures (see #3415 — SeedLoader rejections were silent)", + "evidence": "the grepped log excerpt" + }, + { + "clause": "the console shell is served at /_console/ and the seeded app's merged metadata resolves", + "oracle": "api", + "verify": "GET /_console/ → 200 text/html; GET /api/v1/meta/app?id=com.example.showcase returns the merged app/nav metadata (dogfood skill §1 names this exact endpoint)", + "evidence": "response statuses + top-level keys" + }, + { + "clause": "the served nav matches the authored app: every grp_data object entry from src/ui/apps/index.ts appears in the meta/app response — nothing silently dropped at merge", + "oracle": "api", + "verify": "diff the nav object names in the response against examples/app-showcase/src/ui/apps/index.ts", + "evidence": "the diff (empty)" + } + ], + "negative": [ + "any SeedLoader rejection line in the boot log is a FAIL against the seed (RUNNER rule 3: a defect in the fixture is a fail, not a block — #3408/#3415)", + "a health 200 with ERROR lines in the log is NOT a clean boot — the log clause fails independently of the probe" + ], + "traps": ["seed-data-thin", "stale-dist"], + "source": [ + "dogfood-verification skill §0–§1", + "#3415", + "packages/runtime/src/route-ledger.ts (GET /health, GET /ready)", + "examples/app-showcase/src/ui/apps/index.ts (authored nav)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — standing P0 smoke distilled from the dogfood boot protocol", "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" } + ] + }, + { + "id": "platform-core.seed-integrity", + "title": "Seed integrity: row counts match the authored seed, values land verbatim, replay is idempotent", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a FRESH isolated boot (new file DB) so first-boot seed behavior is what is measured, then a restart against the SAME DB for the idempotence clause" + ] + }, + "steps": [ + "after a clean fresh boot, derive the expected per-object row counts from the app's own seed module (examples/app-showcase/src/data/seed/index.ts — count the records arrays; re-derive rather than trusting a stale list if seeds changed)", + "authoring-time baseline for cross-checking the derivation: showcase_account=14, showcase_contact=33 (9 named + 24 bulk prospects), showcase_project=5, showcase_task=10, showcase_invoice=12, showcase_invoice_line=5, showcase_expense_report=4, showcase_expense_line=13, showcase_field_zoo=2", + "for each seeded object, GET /api/v1/data/<object>?$top=1 and read the server total; build the expected-vs-actual table", + "GET the Field Zoo 'Specimen — Full' row and spot-diff authored values (f_multiselect ['red','green'] as a set, f_lookup resolving to the Northwind account id, f_json nested object intact)", + "re-read the boot log's seed lines: every seed reports success; no rejection or partial-load line", + "restart the server against the SAME file DB; re-run the count sweep and diff against the first sweep (upsert mode must no-op, not duplicate)" + ], + "acceptance": [ + { + "clause": "every seeded object's server row count equals the count authored in the seed module — no silent partial load", + "oracle": "api", + "verify": "expected-vs-actual table from GET /api/v1/data/<object>?$top=1 totals vs the records arrays in src/data/seed/index.ts; zero mismatches", + "evidence": "the table" + }, + { + "clause": "seed VALUES land verbatim, not just rows: the Specimen — Full spot-diff matches the authored literal (arrays as sets, JSON objects structurally, references resolved to real ids)", + "oracle": "api", + "verify": "field-by-field diff of the API read against the seed literal for the sampled fields", + "evidence": "the diff" + }, + { + "clause": "the boot log reports every seed load as success — a rejection is a FAIL against the seed even when the server otherwise boots green (#3415: four of five projects were silently rejected once)", + "oracle": "log", + "verify": "grep the boot log for SeedLoader/seed lines; no rejected/failed entries", + "evidence": "the log excerpt" + }, + { + "clause": "seed replay is idempotent: a restart against the same DB changes no count (upsert with externalId no-ops on unchanged rows)", + "oracle": "api", + "verify": "second count sweep diffs empty against the first", + "evidence": "both sweeps" + }, + { + "clause": "deliberately-unseeded fields stay unseeded for their documented reasons (f_user/f_users: sys_user rows come from sign-up; f_secret: no CryptoProvider on the seed path; task.cover: a managed sys_file cannot be honestly seeded, #4891/ADR-0104) — their absence is CORRECT, not a gap to 'fix'", + "oracle": "api", + "verify": "the Specimen reads show these fields null/absent; record them as expected-absent in the run, never as findings", + "evidence": "the reads + the expected-absent list" + } + ], + "negative": [ + "a count of 0 on any nav-visible object is a FAIL against the seed, never 'feature has nothing to show' (seed-data-thin trap: check row counts vs the built artifact, read the boot log)", + "counts that GROW on restart are a FAIL of upsert idempotence even though every row individually looks valid" + ], + "traps": ["seed-data-thin", "single-datapoint"], + "source": [ + "examples/app-showcase/src/data/seed/index.ts (authored counts + expected-value comments)", + "#3408", + "#3415", + "packages/spec/src/data/seed-loader.zod.ts", + "RUNNER.md trap 'seed-data-thin'" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — makes the #3408/#3415 class of silent seed failure a standing P0 check with exact authored baselines", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.console-login", + "title": "Seeded admin signs in through the console; the session survives reload and re-authenticates cleanly after expiry", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P0", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "steps": [ + "open /_console/ in the browser; screenshot the login form", + "sign in with the seeded admin credentials — drive the React controlled inputs with the native setter + input/change events, or POST the auth endpoint from the page (dogfood skill §4; naive fills submit empty)", + "screenshot the post-login shell; capture the first authed API responses", + "reload the page; capture the first authed API request after reload", + "expire the session: clear the auth cookies for the origin; then trigger an authed navigation/API call", + "observe the console's reaction (redirect to login vs dead shell); screenshot", + "sign in again and verify the console restores a working session on the same route", + "negative pass: sign out, then attempt login with a wrong password; capture the auth response and the UI" + ], + "acceptance": [ + { + "clause": "login succeeds and lands in the console shell (nav + header rendered, not the login form)", + "oracle": "screenshot", + "verify": "post-login screenshot shows the app shell", + "evidence": "screenshot" + }, + { + "clause": "the session survives a reload — the first authed API call after reload returns 200 with no redirect back to login", + "oracle": "network", + "verify": "network trace of the first authed request after reload (e.g. GET /api/v1/meta/app?id=com.example.showcase)", + "evidence": "the trace" + }, + { + "clause": "an expired/cleared session is answered 401 by the SERVER on authed API calls — the deny side of the auth gate, proven on the wire", + "oracle": "network", + "verify": "after clearing cookies, the authed API call in the trace returns 401 (auth is the better-auth passthrough at /api/v1/auth/** — packages/runtime/src/route-ledger.ts; plugin-auth/src/auth-route-ledger.ts)", + "evidence": "the 401 trace" + }, + { + "clause": "the console reacts to expiry by returning the user to login (or an explicit re-auth prompt) — never a dead shell rendering stale data as if authed", + "oracle": "screenshot", + "verify": "post-expiry screenshot shows the login/re-auth surface", + "evidence": "screenshot" + }, + { + "clause": "re-authentication after expiry restores a working session: the same authed calls return 200 again and the shell renders current data", + "oracle": "network", + "verify": "post-re-login trace shows 200s; a mutation or fresh read succeeds", + "evidence": "the trace + screenshot" + } + ], + "negative": [ + "a wrong password is rejected with a visible, named error AND no session cookie is set — a silent no-op or an error-free bounce is a FAIL", + "an expired session that keeps serving the shell with cached data (no 401, no redirect) is a FAIL of the expiry clause" + ], + "traps": ["automation-input", "shared-browser-tab"], + "source": [ + "dogfood-verification skill §4 (React controlled inputs; auth POST fallback)", + "packages/runtime/src/route-ledger.ts ('* /auth/**' better-auth passthrough)", + "--seed-admin credentials: dogfood-verification skill §0" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial", "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" } + ] + }, + { + "id": "platform-core.nav-surfaces-render", + "title": "Every showcase nav surface renders without page errors, and failures surface the error boundary — never a blank page", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P0", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "knownGaps": [ + "no stock showcase fixture deliberately throws inside a route, so the ErrorBoundary fallback ('Something went wrong' + Try Again / Go Home — objectui packages/app-shell/src/chrome/ErrorBoundary.tsx) is verified opportunistically on any failure encountered, plus via the bad-route probe below" + ] + }, + "steps": [ + "run the pinned suite: pnpm -C examples/app-showcase test:smoke (the SURFACES array in e2e/showcase-smoke.spec.ts — 31 surfaces: pages, object lists, dashboards, reports, the view-gallery pages)", + "GET /api/v1/meta/app?id=com.example.showcase and extract every nav destination from the response", + "diff the served nav destinations against the SURFACES array; hand-walk any destination the suite does not cover (screenshot first, then DOM)", + "on each hand-walked surface: wait for render, screenshot, then check for pageerror / empty <main> / placeholder leaks ('no actions configured')", + "probe the failure path: navigate to a nonexistent route under the app (/_console/apps/com.example.showcase/object_that_does_not_exist); screenshot what renders", + "if ANY surface fails during the sweep, verify the failure presents as the shell's error boundary or a named empty/error state — capture it" + ], + "acceptance": [ + { + "clause": "no surface throws a pageerror, renders an empty <main>, or leaks a 'no actions configured' placeholder; chart surfaces draw a real SVG", + "oracle": "test", + "verify": "pnpm -C examples/app-showcase test:smoke (SURFACES array in e2e/showcase-smoke.spec.ts) — green", + "evidence": "test run output" + }, + { + "clause": "the smoke's coverage is CURRENT: every nav destination served in meta/app is either in the SURFACES array or hand-walked this run — no surface silently outside the net", + "oracle": "api", + "verify": "diff of served nav destinations vs SURFACES + the hand-walk records for the remainder", + "evidence": "the diff + per-surface screenshots" + }, + { + "clause": "a bad route renders a NAMED not-found/error state inside the shell — nav and header stay alive, never a white page or dead shell", + "oracle": "dom", + "verify": "after the screenshot confirms the shell rendered, assert the main region carries an explicit empty/error message for the nonexistent object", + "evidence": "screenshot + DOM excerpt" + }, + { + "clause": "any render failure encountered anywhere in the sweep surfaces the route-level ErrorBoundary fallback (recoverable via Try Again), not a blank page", + "oracle": "screenshot", + "verify": "if a failure occurs: screenshot shows the boundary fallback; Try Again re-mounts the route; record none-encountered explicitly otherwise", + "evidence": "failure screenshot or the explicit none-encountered note" + } + ], + "negative": [ + "a surface that renders a blank <main> is a FAIL even with no console error — transitional emptiness must be ruled out by the screenshot-first protocol, then the persistent blank is the finding", + "a bad route producing a white page (shell gone) is a FAIL of the error-boundary clause" + ], + "automated": { "kind": "e2e", "ref": "examples/app-showcase/e2e/showcase-smoke.spec.ts" }, + "traps": ["hydration-race", "single-datapoint", "stale-console-bundle"], + "source": [ + "examples/app-showcase/e2e/showcase-smoke.spec.ts (SURFACES)", + ".github/workflows/showcase-smoke.yml", + "objectui: packages/app-shell/src/chrome/ErrorBoundary.tsx (route-level fallback + recovery actions)", + "examples/app-showcase/src/ui/apps/index.ts (nav truth)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — wraps the existing automated smoke as a checklist row so runs report it alongside manual items", "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" } + ] + }, + { + "id": "platform-core.metadata-registry-serving", + "title": "The metadata registry is served over REST: /meta lists every registered type with its spec-derived create seed", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { "app": "showcase" }, + "steps": [ + "authenticated GET /api/v1/meta — the types listing (entries[]); capture the response", + "check the entries against DEFAULT_METADATA_TYPE_REGISTRY (packages/spec/src/kernel/metadata-plugin.zod.ts): the built-in kinds the showcase registers are present (object, view, page, dashboard, app, action, report, dataset, flow, seed, mapping, permission, position, translation, email_template, doc, book, datasource, api, …)", + "GET /api/v1/meta/types (the richer Studio listing — a distinct server-only route per packages/runtime/src/route-ledger.ts) and capture it", + "GET /api/v1/meta/view/showcase_task and confirm the stored view item is served with its authored shape (list + listViews + formViews keys)", + "GET /api/v1/meta/:type for 'view' and confirm the showcase's authored views are enumerated", + "run the pinned create-seed contract: pnpm --filter @objectstack/dogfood exec vitest run test/meta-types-create-seed.dogfood.test.ts", + "object-extension overlay: GET /api/v1/meta/object/showcase_account and confirm the additive fields contributed by examples/app-showcase/src/data/extensions/account.extension.ts (loyalty_tier select bronze/silver/gold/platinum, linkedin_url url, csat_score number 0–100) are present in the MERGED object — the extension carries priority 210 and never re-declares showcase_account (defineObjectExtension, merged at registerApp)", + "render + round-trip the overlay: open a showcase_account record form and confirm the three extension fields render alongside the base fields; PATCH /api/v1/data/showcase_account/<id> setting loyalty_tier='gold' and csat_score=88, then GET the row and confirm both persisted (the overlay is a real column, not a display-only badge)" + ], + "acceptance": [ + { + "clause": "GET /api/v1/meta answers 200 with a non-empty entries[] naming the registered metadata types", + "oracle": "api", + "verify": "response parses; entries.length > 0; the registry kinds listed in the steps are all present", + "evidence": "the response + the presence table" + }, + { + "clause": "entries carry the AUTHORITATIVE spec-derived create seeds: dashboard's seed is {widgets: []}, action's is a script with a valid js body, and report exposes NO seed by design (canvas-create) — consumers derive create defaults from the spec, not re-invent them", + "oracle": "test", + "verify": "pnpm --filter @objectstack/dogfood exec vitest run test/meta-types-create-seed.dogfood.test.ts — green (it asserts entry.createSeed equals getMetadataCreateSeed(type) for every seeded registered type)", + "evidence": "test run output" + }, + { + "clause": "GET /api/v1/meta/types (the richer Studio-facing listing) is served — a real route distinct from GET /meta, per the route ledger", + "oracle": "api", + "verify": "authenticated GET returns 200 with the type registry payload", + "evidence": "the response" + }, + { + "clause": "a stored item is retrievable by type+name with its authored shape: GET /api/v1/meta/view/showcase_task returns the task view gallery as authored", + "oracle": "api", + "verify": "the response carries the authored keys (list, listViews incl. in_progress/board/gantt, formViews incl. wizard/quick)", + "evidence": "the response's key inventory" + }, + { + "clause": "type-scoped listing works: GET /api/v1/meta/view enumerates the showcase's authored views (task, project, contact, field-zoo, business-unit, inquiry families present)", + "oracle": "api", + "verify": "the listing contains the expected view names from examples/app-showcase/src/ui/views/", + "evidence": "the listing" + }, + { + "clause": "an object extension merges ADDITIVELY into the served object: GET /api/v1/meta/object/showcase_account carries the account.extension.ts fields (loyalty_tier, linkedin_url, csat_score) merged in without re-declaring the object — the mechanism a package uses to extend an object it does not own", + "oracle": "api", + "verify": "the /meta/object response's fields include all three overlay fields with their declared types/options; showcase_account is authored once (defineObjectExtension priority 210 wins on conflict, merged at registerApp)", + "evidence": "the merged field inventory" + }, + { + "clause": "the overlay fields are real columns, not display-only: they render on the showcase_account form and a write to loyalty_tier + csat_score round-trips through /api/v1/data/showcase_account", + "oracle": "api", + "verify": "PATCH /api/v1/data/showcase_account/<id> {loyalty_tier:'gold', csat_score:88} then GET the row returns both values; a screenshot confirms the fields render on the form", + "evidence": "the PATCH + GET pair + the form screenshot" + } + ], + "negative": [ + "a registered type missing from GET /meta (or served without its registry contract) is a FAIL — the Studio designer derives its create UX from this response, and drift here produced real create-save 422s (the dashboard-layout / action-body incidents the pinned test memorializes)", + "an extension field present in account.extension.ts but ABSENT from GET /meta/object/showcase_account (or present in /meta but not writable through /data) is a FAIL — the overlay did not merge, and a package's additive fields would silently vanish", + "an unauthenticated GET /api/v1/meta that returns the full registry is a finding for the access-security area — capture and cross-file it, do not tick past it" + ], + "traps": ["dispatcher-vs-hono-route"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts" }, + "source": [ + "packages/spec/src/kernel/metadata-plugin.zod.ts (MetadataTypeSchema + DEFAULT_METADATA_TYPE_REGISTRY)", + "packages/spec/src/kernel/metadata-create-seeds.ts", + "packages/runtime/src/route-ledger.ts (GET /meta, GET /meta/types, GET /meta/:type, GET /meta/:type/:name)", + "examples/app-showcase/src/data/extensions/account.extension.ts (AccountExtension — additive overlay on showcase_account, priority 210)", + "packages/spec/src/data (defineObjectExtension — extend merges fields at registerApp, higher priority wins on conflict)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — pins the registry-serving contract the Studio create path depends on", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-08", "change": "added the object-extension-overlay clauses: account.extension.ts (Loyalty Tier / LinkedIn / CSAT) merges additively into /meta/object/showcase_account, renders on the form, and round-trips a write", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.metadata-authoring-roundtrip", + "title": "Metadata authoring round-trip: draft → publish on a WRITABLE package; read-only packages and locked types are server-side refused", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a runtime-created WRITABLE package to author into (create one via Studio or POST /api/v1/packages) — the showcase's own code package is read-only by design (ADR-0070)" + ] + }, + "steps": [ + "create a writable package (Studio package switcher → new, or POST /api/v1/packages); record its id", + "save a draft view into it: PUT /api/v1/meta/view/os_qa_view_<runid> with a minimal valid ListView body bound to showcase_task and the writable packageId; capture the response", + "GET /api/v1/meta/_drafts and confirm the draft is listed", + "publish: POST /api/v1/packages/<id>/publish-drafts; then GET /api/v1/meta/view/os_qa_view_<runid>/published and capture it", + "verify in the console that the published view is live for showcase_task (reload, open the view switcher)", + "denied side A: attempt the same runtime create targeting the read-only CODE package; capture the refusal (expect writable_package_required — ADR-0070 D1)", + "denied side B: attempt a PUT overlay against a PACKAGED object item (type 'object' has allowOrgOverride: false); capture the refusal (expect HTTP 403 not_overridable)", + "Studio browser half: create a new record page via Studio bound to an object and capture the PUT /api/v1/meta/page/... it issues" + ], + "acceptance": [ + { + "clause": "a draft saved via PUT /api/v1/meta/view/:name persists and is listed by GET /api/v1/meta/_drafts", + "oracle": "api", + "verify": "the PUT succeeds; the drafts listing contains os_qa_view_<runid> (routes per packages/runtime/src/route-ledger.ts: PUT /meta/:type/:name, GET /meta/_drafts)", + "evidence": "PUT response + drafts listing" + }, + { + "clause": "publish-drafts promotes the draft: GET /meta/view/:name/published serves the authored body afterwards, and the console renders the view after reload", + "oracle": "api", + "verify": "POST /packages/:id/publish-drafts → success; the /published read returns the body; console screenshot shows the view in the switcher", + "evidence": "publish response + published read + screenshot" + }, + { + "clause": "Studio's designer authors through the same pipeline: creating a record page issues PUT /api/v1/meta/page/<name> bound to its object and seeded from the default layout", + "oracle": "network", + "verify": "capture the PUT during Studio create (pinned by objectui e2e/live/studio-record-page.spec.ts, which waits on exactly that request)", + "evidence": "the captured PUT" + }, + { + "clause": "DENY side of the package gate: a runtime-only create targeting a read-only code/installed package is REJECTED with writable_package_required — not silently coerced to a package-less orphan (the pre-ADR-0070 #2252 bug)", + "oracle": "api", + "verify": "the refusal names writable_package_required (pinned by packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts)", + "evidence": "the refusal response" + }, + { + "clause": "DENY side of the overlay gate: a per-org overlay write against a type with allowOrgOverride unset (object/field) answers HTTP 403 not_overridable, while view/dashboard (the ADR-0005 Phase 1 opt-ins) accept", + "oracle": "api", + "verify": "the object-targeting PUT returns 403 not_overridable (contract stated on allowOrgOverride in packages/spec/src/kernel/metadata-plugin.zod.ts); the view PUT from the happy path succeeded", + "evidence": "both responses side by side" + }, + { + "clause": "a malformed body is refused by schema validation, never stored: a PUT with an invalid shape for the type answers a named validation error (validateOnWrite; the #5206 lesson — an unvalidated store is the defect)", + "oracle": "api", + "verify": "PUT /api/v1/meta/view/os_qa_bad_<runid> with a nonsense body (e.g. columns: 42) → 4xx naming the validation failure; a subsequent GET finds no stored item", + "evidence": "the refusal + the empty GET" + } + ], + "negative": [ + "a 200 on either deny-side attempt (read-only package, locked overlay type) is a FAIL — both gates are server-side contracts, not Studio courtesies", + "a malformed metadata body stored as-is (200 on PUT, garbage on GET) is a FAIL — this is the exact pre-#5271 'api' kind hole", + "a published view that never appears in the console after reload is a FAIL of the round-trip even though every API call returned success (check against a fresh objectui build before filing — stale-console-bundle)" + ], + "traps": ["dispatcher-vs-hono-route", "stale-console-bundle"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts; objectui: e2e/live/studio-record-page.spec.ts, e2e/live/studio-object-designer.spec.ts" }, + "source": [ + "packages/runtime/src/route-ledger.ts (PUT /meta/:type/:name, GET /meta/_drafts, GET /meta/:type/:name/published, POST /packages/:id/publish-drafts)", + "packages/spec/src/kernel/metadata-plugin.zod.ts (allowOrgOverride 403 not_overridable contract; validateOnWrite; registry flags per type)", + "ADR-0070 via packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts (writable_package_required)", + "ADR-0033 (drafts / publish)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "initial — grounds the Studio authoring pipeline end-to-end with both deny gates as first-class clauses", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.builtin-apps-nav-render", + "title": "The three built-in apps (Setup / Studio / Account) render every merged-nav destination; app-level and entry-level gates are enforced, not errored", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a plain member (fresh runtime sign-up — lands in member_default, holds neither setup.access nor studio.access)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the three platform apps ship with @objectstack/platform-objects and register as one-app packages @objectstack/{setup,studio,account} (ADR-0048), so their ids are com.objectstack.setup / com.objectstack.studio / com.objectstack.account — present on any stock boot, no showcase-specific fixture", + "a second, non-admin persona: sign up a fresh user in-run to drive the app-level and entry-level permission gates (do not reuse the admin — wrong-persona masks the guard)" + ], + "knownGaps": [ + "the SSO Providers entry (nav_sso_providers → sys_sso_provider) is contributed ONLY when the external-IdP RP is wired (OS_SSO_ENABLED self-host / cloud planAllowsSso — plugin-auth auth-plugin.ts isSsoWired()). On a stock open-framework boot it is ABSENT by design (not gated-and-erroring); assert its absence, never treat it as a missing surface" + ] + }, + "steps": [ + "as admin, GET /api/v1/meta/app?id=com.objectstack.setup and extract every merged nav destination — Setup is a SHELL of empty group anchors (packages/platform-objects/src/apps/setup.app.ts: group_overview/apps/people_org/access_control/approvals/configuration/diagnostics/integrations/advanced) filled by SETUP_NAV_CONTRIBUTIONS (setup-nav.contributions.ts) plus capability-plugin contributions (plugin-security Positions/Permission Sets, plugin-sharing Sharing Rules/Record Shares, plugin-approvals, plugin-webhooks)", + "GET /api/v1/meta/app?id=com.objectstack.studio (studio.app.ts — static nav: Overview, Data Model, User Experience, Logic, Automation, AI, Developer, Integration) and GET /api/v1/meta/app?id=com.objectstack.account (account.app.ts — Profile + Inbox/Security/Developer groups)", + "as admin, hand-walk each destination in each app: navigate, wait for render, SCREENSHOT FIRST, then read the DOM — assert no pageerror, no 'failed to load', no blank <main>, no 'no actions configured' placeholder", + "Setup detail: confirm each settings URL entry (nav_settings_* → /apps/setup/system/settings/<namespace>) opens the settings namespace form (localization/company/branding/auth/mail/storage/ai/knowledge/feature_flags), and Users / Organization / Business Units / Teams / Sessions / OAuth Applications / Identity Links / User Preferences render", + "Studio detail: confirm each metadata:resource list (object/app/view/page/dashboard/report/dataset/action/hook/flow/agent/tool/skill/email_template) renders, and the component surfaces render (App Builder studio:builder, All Metadata Types metadata:directory, Packages developer:packages, API Console developer:api-console, Flow Runs developer:flow-runs, Public Forms developer:public-forms)", + "Account detail: confirm Profile (account:profile_card), Notifications (sys_inbox_message/mine), Approvals (sys_approval_request/my_pending), Linked Accounts (sys_account), Active Sessions (sys_session/mine), API Keys (sys_api_key/mine), OAuth Applications (sys_oauth_application/mine) each render", + "confirm the gated entries resolve to ABSENT-not-erroring for the admin: nav_organizations (requiresService org-scoping) is absent in single-org mode; nav_jwks Signing Keys (requiredPermissions manage_platform_settings, sys_jwks private per ADR-0066) is PRESENT for admin; SSO Providers is absent unless OS_SSO_ENABLED (knownGap)", + "sign in as the plain member: attempt to open Setup (com.objectstack.setup) and Studio (com.objectstack.studio) — capture the app-level refusal (App.requiredPermissions setup.access / studio.access); open Account (declares no requiredPermissions) — capture it opening", + "as the member, GET /api/v1/meta/app for a reachable app and confirm every manage_platform_settings-gated entry (JWKS, API Keys, the manage_platform_settings settings URLs) is ABSENT from the member's payload — the server prunes, the client does not merely hide", + "cross-check served-vs-rendered: diff each app's meta/app nav destinations against what actually rendered so nothing is silently outside the walk" + ], + "acceptance": [ + { + "clause": "every merged-nav destination in all three apps renders a real surface for the admin — no pageerror, no 'failed to load', no blank <main>, no placeholder leak; this closes the gap platform-core.nav-surfaces-render leaves (it only sweeps the showcase app)", + "oracle": "screenshot", + "verify": "per-destination screenshot (screenshot-first) then DOM mark-check, across Setup + Studio + Account", + "evidence": "per-app per-destination screenshot set + verdict table" + }, + { + "clause": "the served merged nav matches each app's authored shell + contributions: Setup's group anchors (setup.app.ts) are filled by SETUP_NAV_CONTRIBUTIONS and capability plugins; Studio's static groups (studio.app.ts) are all present; Account's Profile/Inbox/Security/Developer groups present", + "oracle": "api", + "verify": "the three GET /meta/app payloads list the expected group ids + entries per the app source files", + "evidence": "the three nav payloads" + }, + { + "clause": "as a plain member, Setup and Studio REFUSE (app-level requiredPermissions setup.access / studio.access) with a named access-denied surface — never a blank shell — while Account OPENS (declares no requiredPermissions, RLS scopes its rows)", + "oracle": "screenshot", + "verify": "run the two refusals + the Account open as the member persona (wrong-persona trap — do it as the member, not the admin)", + "evidence": "three screenshots" + }, + { + "clause": "the app-level gate is server-side, not a client courtesy: a forged member GET /api/v1/meta/app?id=com.objectstack.setup is denied/empty at the server, not merely hidden in the launcher (ADR-0057 D10 both-sides)", + "oracle": "api", + "verify": "the forged request's status/body proves server-side denial", + "evidence": "the forged response" + }, + { + "clause": "permission-gated ENTRIES are absent from the member's merged nav, not present-and-erroring: JWKS (nav_jwks), API Keys (nav_api_keys) and the manage_platform_settings settings entries do not appear in the member's payload", + "oracle": "api", + "verify": "diff of admin vs member /meta/app nav destinations — the gated entries are only in the admin set", + "evidence": "the admin-vs-member diff" + }, + { + "clause": "service/object-gated entries resolve to ABSENCE on stock fixtures rather than an entry that can only error: nav_organizations (requiresService org-scoping) absent in single-org mode; nav_business_units (requiresObject sys_business_unit) present only when the object is registered", + "oracle": "api", + "verify": "the gated-entry presence in the served payload matches the requiresService/requiresObject conditions in setup-nav.contributions.ts / account.app.ts", + "evidence": "the gated-entry presence table" + }, + { + "clause": "an off-capability destination is PRUNED from the nav, never rendered as a dead 'failed to load' list — the setup-nav.contributions.ts rationale (sys_verification/sys_device_code omit list; SSO absent unless wired) holds at runtime", + "oracle": "screenshot", + "verify": "the absent entries do not appear; SSO Providers absent on stock boot per the knownGap", + "evidence": "absence confirmation + the knownGap note" + } + ], + "negative": [ + "a built-in app destination that renders a blank <main> or a 'failed to load' / 'no actions configured' placeholder with no error boundary is a FAIL (screenshot-first rules out transitional emptiness, then the persistent blank is the finding)", + "the member reaching Setup or Studio content (app gate bypassed) is a FAIL — App.requiredPermissions is a server contract, prove denial on the wire, not just a hidden launcher tile", + "a permission-gated entry rendered for the member and then erroring on click is a FAIL of the 'absent not erroring' contract — the nav must prune server-side" + ], + "traps": ["hydration-race", "wrong-persona", "stale-console-bundle"], + "source": [ + "packages/platform-objects/src/apps/setup.app.ts (shell group anchors + requiredPermissions setup.access)", + "packages/platform-objects/src/apps/studio.app.ts (static nav; requiredPermissions studio.access)", + "packages/platform-objects/src/apps/account.app.ts (no requiredPermissions; hidden from App Switcher; requiresObject/requiresService entry gates)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_jwks manage_platform_settings; nav_organizations requiresService org-scoping; nav_api_keys manage_platform_settings)", + "packages/plugins/plugin-auth/src/auth-plugin.ts (SSO Providers nav_sso_providers contributed only when isSsoWired())", + "ADR-0048 (Setup/Studio/Account as one-app packages com.objectstack.{setup,studio,account}); ADR-0029 (nav contributions); ADR-0066 (sys_jwks private)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — the big built-in-apps nav sweep (Setup/Studio/Account merged nav render + app-level and entry-level gates both-sides), complementing nav-surfaces-render which only walks the showcase app", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.settings-hub-roundtrip", + "title": "Settings hub round-trip: a value saves, PERSISTS, reaches an observable consumer, audits, env-locks, tests honestly, and stores secrets as handles — anonymous denied", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — holds setup.access/setup.write/manage_platform_settings)", + "anonymous" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the 10 open-framework settings namespaces registered by @objectstack/service-settings (packages/services/service-settings/src/manifests: localization, company, branding, auth, mail, sms, storage, ai, knowledge, feature_flags)", + "the settings service wired with a data engine so it swaps from in-memory to the sys_setting table, and with the sys_secret store + auditWriter (SettingsServicePlugin binds these on kernel:ready; the showcase stack mounts the engine)" + ], + "knownGaps": [ + "HONORING the ai.json caveat: a saved settings FORM is NOT proof of effect — the AI namespace's test action is a stub validator until @objectstack/service-ai is mounted, and a saved ai.provider does nothing observable without it. The effect clause therefore uses localization, whose consumer (resolve-authz-context.ts reads localization.timezone/locale/currency onto every ExecutionContext) is stock and observable; branding.workspace_name in the shell is the browser fallback. Never tick effect off a persisted form." + ] + }, + "steps": [ + "as admin, GET /api/settings — capture the visible manifests (the 10 open-framework namespaces above; the route is the SettingsService REST surface in settings-routes.ts)", + "pick localization (an OBSERVABLE-CONSUMER namespace — packages/core/src/security/resolve-authz-context.ts reads localization.timezone/locale/currency onto ExecutionContext). GET /api/settings/localization — capture {manifest, values}; note timezone value 'UTC' with source 'default'", + "change it: PUT /api/settings/localization { timezone: 'Asia/Tokyo' }; re-read GET /api/settings/localization and confirm the value persists and source FLIPS default→tenant (localization scope is 'tenant')", + "prove EFFECT (not just the saved form): re-derive an ExecutionContext-dependent output — an analytics date-bucket aggregate or a today()/rendered-datetime on showcase_task — and confirm it now resolves against Asia/Tokyo, not UTC. If the deployment surfaces no observable localization effect, fall back to branding.workspace_name visible in the console shell — but never tick effect off the form alone", + "audit: GET /api/v1/data/sys_setting_audit filtered to namespace=localization,key=timezone — a row exists with action='set', source='api', actor_id=<admin>, new_hash set (SettingsServicePlugin.buildAuditWriter → sys_setting_audit)", + "source badge: open /apps/setup/system/settings/localization and confirm the timezone field's source indicator flips from default to the persisted scope after save; screenshot", + "env-lock: set OS_LOCALIZATION_TIMEZONE=Europe/Paris (envKeyOf convention: OS_<NAMESPACE>_<KEY>, settings-service.types.ts) and restart; GET /api/settings/localization now reports timezone source='env', locked=true, lockedReason 'Set via env: OS_LOCALIZATION_TIMEZONE', and the console renders the EnvLockBadge; PUT /api/settings/localization { timezone: 'UTC' } is REFUSED 409 SETTINGS_LOCKED (server-side, effectiveEnvOverride)", + "test action at the dev transport: POST /api/settings/mail/test with provider=log (or no email plugin mounted) — the handler answers ok:false and NO mail is faked (mail.manifest.ts mailTestActionHandler / plugin-email honest degradation, framework#5087)", + "secret handling: on a namespace with an encrypted specifier (mail.smtp_password or ai.*_api_key — type 'password' or encrypted:true), PUT a value; then read sys_secret + sys_setting — the ciphertext lands in sys_secret keyed by a 'sec_' handle and sys_setting.value_enc holds the handle id, NOT plaintext; GET /api/settings never returns the plaintext", + "anonymous deny: GET /api/settings/localization with no session → 403 SETTINGS_FORBIDDEN (assertPermitted read); GET /api/settings (list) as anon returns an EMPTY manifest set — zero namespaces enumerated (Finding-1 secure default)", + "guardrails: PUT /api/settings/localization { timezone: 'Mars/Phobos' } → 400 SETTINGS_VALIDATION invalid_option (declared options table); PUT { bogus_key: 1 } → 400 UNKNOWN_KEY" + ], + "acceptance": [ + { + "clause": "a settings value round-trips: PUT /api/settings/localization persists and GET re-reads it, with source FLIPPING default→tenant (the scope), not staying 'default'", + "oracle": "api", + "verify": "the PUT + GET responses; the timezone value is Asia/Tokyo and source is the scope, not 'default'", + "evidence": "the PUT + GET bodies with the source field" + }, + { + "clause": "EFFECT, not just a form: the saved value reaches its consumer — localization.timezone resolves onto ExecutionContext (resolve-authz-context.ts), so an ExecutionContext-dependent output (analytics date bucket / today() / rendered datetime) shifts from UTC to the saved zone", + "oracle": "api", + "verify": "the before/after output pair differs by exactly the zone change; NOTE (ai.json caveat) this clause requires an observable consumer — only localization/branding/auth qualify on stock fixtures, a persisted form is NOT proof", + "evidence": "the before/after consumer output" + }, + { + "clause": "every write appends a sys_setting_audit row (namespace/key/scope/action='set'/source='api'/actor_id/new_hash)", + "oracle": "api", + "verify": "GET /api/v1/data/sys_setting_audit shows the row for localization.timezone (SettingsServicePlugin.buildAuditWriter)", + "evidence": "the audit row" + }, + { + "clause": "the console source badge flips from default to the persisted-scope source after save", + "oracle": "screenshot", + "verify": "before/after field screenshots of the localization timezone source indicator", + "evidence": "the two screenshots" + }, + { + "clause": "an OS_*-env-pinned key is server-authoritative: GET reports source='env' locked=true, and PUT is REFUSED 409 SETTINGS_LOCKED — the write refusal is the SERVER's, not the UI's", + "oracle": "api", + "verify": "the locked GET (source='env', lockedReason names OS_LOCALIZATION_TIMEZONE) + the 409 on the write (effectiveEnvOverride)", + "evidence": "the locked GET + the 409" + }, + { + "clause": "the env-pinned field renders the EnvLockBadge and is non-editable in the console", + "oracle": "screenshot", + "verify": "screenshot of the locked field with the badge", + "evidence": "the screenshot" + }, + { + "clause": "a declared test action does not fake success: POST /api/settings/mail/test with no deliverable transport answers ok:false (400 SETTINGS_ACTION_FAILED envelope) naming that no mail was sent", + "oracle": "api", + "verify": "the action response body (ok:false, message names the honest non-delivery — framework#5087)", + "evidence": "the action response" + }, + { + "clause": "an encrypted specifier's value lands in sys_secret as a handle, never plaintext: sys_setting.value_enc holds a 'sec_' handle id and sys_secret holds the ciphertext; GET /api/settings never echoes the plaintext", + "oracle": "api", + "verify": "the sys_secret row (id starts 'sec_', ciphertext present) + sys_setting.value_enc = that handle + the redacted GET (materialiseRow dereferences through sys_secret)", + "evidence": "the sys_secret row + sys_setting.value_enc + the redacted GET" + }, + { + "clause": "anonymous is denied: GET /api/settings/:namespace → 403 SETTINGS_FORBIDDEN, and GET /api/settings lists ZERO namespaces for an unauthenticated caller (no enumeration — Finding-1)", + "oracle": "api", + "verify": "the two anonymous responses", + "evidence": "the 403 + the empty list" + }, + { + "clause": "each of the 10 open-framework namespaces resolves GET /api/settings/:ns with its manifest + values — no namespace 500s or serves an empty manifest", + "oracle": "api", + "verify": "one GET per variant in variants[]; each returns {manifest, values} with the manifest's specifiers", + "evidence": "the per-namespace responses" + } + ], + "negative": [ + "a saved settings form treated as proof of effect (no observable consumer checked) is the ai.json anti-pattern — ticking the effect clause on the form alone is a FALSE PASS", + "a 200 on an env-locked PUT is a FAIL (the lock is a server contract, effectiveEnvOverride)", + "an encrypted value returned as plaintext by GET /api/settings, or stored inline in sys_setting.value rather than sys_secret, is a FAIL", + "a test action answering ok:true for a send that did not happen is a FAIL (framework#5087)", + "GET /api/settings enumerating namespaces for an anonymous caller is a FAIL (Finding-1 — the old default trusted x-user-id/x-permissions headers)" + ], + "variants": [ + "localization", + "company", + "branding", + "auth", + "mail", + "sms", + "storage", + "ai", + "knowledge", + "feature_flags" + ], + "traps": ["stale-console-bundle", "dispatcher-vs-hono-route"], + "source": [ + "packages/services/service-settings/src/settings-routes.ts (GET/PUT /api/settings, POST :ns/:actionId; 403 SETTINGS_FORBIDDEN / 409 SETTINGS_LOCKED / 400 SETTINGS_VALIDATION|UNKNOWN_KEY mapping; secure anonymous default)", + "packages/services/service-settings/src/settings-service.ts (cascade source default→scope; effectiveEnvOverride lock; encrypted→sys_secret handle via cryptoProvider+secretStore; validatePatch invalid_option)", + "packages/services/service-settings/src/settings-service-plugin.ts (verifiedContextFromRequest fail-closed; buildAuditWriter→sys_setting_audit; buildSecretStore→sys_secret; LocalCryptoProvider)", + "packages/services/service-settings/src/settings-service.types.ts (envKeyOf OS_<NAMESPACE>_<KEY>)", + "packages/services/service-settings/src/manifests/{localization,mail,ai,branding}.manifest.ts", + "packages/core/src/security/resolve-authz-context.ts (localization timezone/locale/currency → ExecutionContext — the observable consumer)", + "framework#5087 (a test action must not fake success), #5204 (env override enforcement)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — settings hub round-trip with persistence + observable-consumer effect (honoring the ai.json form-is-not-effect caveat), sys_setting_audit, env-lock server refusal, honest test action, sys_secret handle-not-plaintext, anonymous deny", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.package-lifecycle-enable-disable", + "title": "Package lifecycle: disable stops serving, enable restores, uninstall de-registers, commits grow per publish and revert restores the prior published shape", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a runtime-created WRITABLE probe package — the SAME fixture platform-core.metadata-authoring-roundtrip creates via POST /api/v1/packages (reuse it, or create one in-run); author an object + a view + a nav entry into it and publish so it actually serves objects/nav/routes. The showcase's own code package is read-only by design (ADR-0070), so it cannot be the probe" + ], + "knownGaps": [ + "if the deployment blocks runtime package install/enable (e.g. a locked-down prod profile), record blocked(fixture) — the lifecycle routes still exist (route-ledger.ts) but there is nothing writable to drive them against" + ] + }, + "steps": [ + "create or reuse the writable probe package (POST /api/v1/packages); author a probe object + view + nav entry into it and publish (POST /api/v1/packages/:id/publish or /publish-drafts). Record its id", + "confirm it serves: GET /api/v1/meta lists the probe object; GET /api/v1/data/<probe_object> answers 200; the probe nav appears in the relevant /api/v1/meta/app", + "disable: PATCH /api/v1/packages/:id/disable → capture; then GET /api/v1/data/<probe_object> and the probe routes → now 404/403 (stopped serving); /meta/app no longer lists its nav", + "enable: PATCH /api/v1/packages/:id/enable → capture; the object/nav/routes serve again (restore)", + "commits grow per publish: GET /api/v1/packages/:id/commits before and after a second publish — the list grows by exactly one entry; capture both listings", + "revert restores the prior published shape: change the probe (e.g. add a field) and publish, then POST /api/v1/packages/:id/commits/:commitId/revert (or POST /api/v1/packages/:id/revert) → GET the /published shape and confirm it is back to the prior commit", + "uninstall: DELETE /api/v1/packages/:id → capture; GET /api/v1/meta no longer lists the probe object/kinds and GET /api/v1/packages no longer lists the package", + "both-sides deny: attempt PATCH /disable and DELETE against a READ-ONLY code package (a platform package or the showcase's own) → refused server-side (ADR-0070); capture the refusal" + ], + "acceptance": [ + { + "clause": "disable STOPS serving: after PATCH /packages/:id/disable, the package's objects/routes answer 404/403 and /meta/app drops its nav — disable is an enforcement, not a cosmetic flag", + "oracle": "api", + "verify": "GET /api/v1/data/<probe_object> and the probe routes return 404/403 after disable (were 200 before)", + "evidence": "before/after reads" + }, + { + "clause": "enable RESTORES: PATCH /packages/:id/enable and the same objects/nav/routes serve again", + "oracle": "api", + "verify": "the post-enable reads return 200 and the nav is back", + "evidence": "before/after reads" + }, + { + "clause": "uninstall DE-REGISTERS: DELETE /packages/:id, then GET /meta no longer lists its kinds and GET /packages no longer lists it", + "oracle": "api", + "verify": "the meta + packages listings after DELETE", + "evidence": "the two listings" + }, + { + "clause": "the commits list GROWS per publish: GET /packages/:id/commits gains exactly one entry per publish", + "oracle": "api", + "verify": "commit count after the second publish == count before + 1", + "evidence": "the two commit listings" + }, + { + "clause": "revert RESTORES the prior published shape: POST /packages/:id/commits/:commitId/revert (or /revert) returns the published metadata to the prior commit, and a subsequent /published read matches the pre-change shape", + "oracle": "api", + "verify": "the pre-change /published read equals the post-revert /published read", + "evidence": "the pre/post published reads" + }, + { + "clause": "the disabled package's surface is gone from the console — its nav entry is absent and a stale deep-link to its object shows a named not-found inside the shell, never a dead white page", + "oracle": "screenshot", + "verify": "screenshot the console after disable (nav absent) and a stale deep-link (named not-found in the shell)", + "evidence": "the two screenshots" + }, + { + "clause": "DENY side: PATCH /disable and DELETE against a read-only code package are refused server-side (ADR-0070), not silently applied", + "oracle": "api", + "verify": "the refusal status/code on the read-only package", + "evidence": "the refusal" + }, + { + "clause": "state is coherent across the cycle: a disabled-then-enabled package's DATA rows survive the toggle (disable stops serving, it does not destroy rows)", + "oracle": "api", + "verify": "row count before disable == row count after re-enable", + "evidence": "the two counts" + } + ], + "negative": [ + "a disabled package still serving its objects/routes (200 on /data/<probe_object>) is a FAIL — disable is an enforcement, not a flag", + "uninstall leaving orphaned metadata in /meta (or the package still in GET /packages) is a FAIL", + "a revert that does not restore the prior published shape, or a commits list that grows without a working revert, is a FAIL", + "a 2xx on the read-only-package disable/uninstall attempt is a FAIL of the ADR-0070 gate" + ], + "traps": ["dispatcher-vs-hono-route", "stale-console-bundle"], + "source": [ + "packages/runtime/src/route-ledger.ts (PATCH /packages/:id/enable|disable, DELETE /packages/:id, POST /packages/:id/publish|publish-drafts, GET /packages/:id/commits, POST /packages/:id/commits/:commitId/revert, POST /packages/:id/revert)", + "platform-core.metadata-authoring-roundtrip (the writable probe package this reuses)", + "ADR-0070 (writable vs read-only packages), ADR-0033 (drafts/publish/commits)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — package enable/disable/uninstall + commits/revert lifecycle, driven against the writable probe package the authoring round-trip already creates", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.notification-center", + "title": "Notification center (bell / InboxPopover): badge counts distinct unread topics + pending approvals, repeats coalesce, per-group and global mark-read drop the badge", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a recurring notification that produces many identical rows (the showcase_scheduled_digest firing once a minute — the exact flood inboxGrouping.ts was written for), so the topic-coalescing is exercised rather than assumed", + "at least one pending approval request (sys_approval_request / my_pending) so the approvals portion of the badge and the Approvals tab have something to show" + ], + "knownGaps": [ + "rows without a notification_id (legacy/synthetic inbox rows) flip read optimistically but do NOT persist server-side — read-state lives in sys_notification_receipt keyed by the L2 event id (ADR-0030), so only rows carrying a notification_id survive reload. Verify persistence on a keyed row, and record synthetic rows as expected-non-persistent" + ] + }, + "steps": [ + "open the console as admin; the header (AppHeader.tsx) polls GET /api/v1/notifications?view=mine — capture the initial inbox rows", + "read the bell badge: it must equal distinct unread (topic,title) TOPICS + pending approvals, NOT raw row count (#2765) — the recurring digest producing many identical rows must not inflate it to 9+ off one topic", + "cross-check: count distinct unread (topic,title) groups from the payload (groupNotifications in inboxGrouping.ts) + pendingApprovalsCount and confirm it equals the rendered totalBadge", + "open the popover; confirm THREE tabs render: Notifications / Approvals / Activity", + "confirm coalescing: repeats of the same (topic,title) collapse into ONE expandable group with a ×N count pill; click the chevron to expand and reveal the members", + "per-group mark-read: click a group's 'Mark read' → capture POST /api/v1/notifications/read {ids:[...]} on the wire (ONE request for the whole group, not N); the group's unread dot clears and the badge drops by one topic", + "global mark-all-read: click 'Mark all read' → capture POST /api/v1/notifications/read/all; the notifications portion of the badge drops to 0; the approvals portion is unaffected", + "Approvals tab: confirm it lists/points at the pending approval request(s) and pendingApprovalsCount equals the sys_approval_request my_pending count", + "persistence: reload; read-state persists for rows carrying a notification_id (sys_notification_receipt upsert, ADR-0030); synthetic rows without one flip only optimistically (knownGap)" + ], + "acceptance": [ + { + "clause": "the badge counts distinct unread TOPICS + pending approvals, not raw rows — a recurring digest producing N identical rows contributes 1 to the badge (#2765)", + "oracle": "network", + "verify": "distinct-(topic,title) unread group count from the GET /notifications payload + pendingApprovalsCount == the rendered totalBadge", + "evidence": "the payload + the badge value" + }, + { + "clause": "three tabs render (Notifications / Approvals / Activity)", + "oracle": "screenshot", + "verify": "the popover shows all three TabsTrigger surfaces", + "evidence": "popover screenshot" + }, + { + "clause": "(topic,title) repeats COALESCE into one expandable group with a ×N pill; expanding reveals the members (a group of one renders as a plain row, no pill)", + "oracle": "dom", + "verify": "after the screenshot confirms the popover rendered, assert the group row carries the ×N pill and the expanded list holds N members", + "evidence": "collapsed + expanded screenshots + DOM excerpt" + }, + { + "clause": "per-group mark-read issues POST /api/v1/notifications/read {ids} as a SINGLE request for the whole group (not one per row), and the group's unread state clears", + "oracle": "network", + "verify": "the captured POST carries the group's member ids in one body; the badge drops by one topic", + "evidence": "the captured POST + before/after badge" + }, + { + "clause": "global mark-all-read issues POST /api/v1/notifications/read/all and drops the notifications portion of the badge to 0", + "oracle": "network", + "verify": "the captured POST + the badge's notifications portion at 0 afterward", + "evidence": "the captured POST + badge" + }, + { + "clause": "the Approvals tab lists the pending request(s) and pendingApprovalsCount equals the sys_approval_request my_pending count", + "oracle": "dom", + "verify": "the tab content + the count vs a direct sys_approval_request my_pending query", + "evidence": "the tab + the count comparison" + }, + { + "clause": "read-state PERSISTS across reload for rows carrying a notification_id (sys_notification_receipt upsert, ADR-0030) — after mark-read + reload the keyed row stays read; a mark-read that reverts on the next poll is the pre-ADR-0030 receipt bug", + "oracle": "network", + "verify": "the post-reload GET /notifications shows the keyed row still read", + "evidence": "the post-reload payload" + } + ], + "negative": [ + "a badge showing raw unread ROW count (inflating to 9+ off one recurring topic) is the #2765 regression — FAIL", + "mark-read firing one POST per row for a coalesced group (instead of one {ids} request) is a FAIL of the coalescing contract", + "read-state reverting to unread on the next poll for a keyed row (writing sys_notification_receipt through the generic data API, which rejects it — ADR-0103) is a FAIL", + "cross-ref: this is distinct from i18n.notification-localized-and-clears (single-entry localization + clear) and approvals.notification-deep-link (deep-link) — do not double-count their coverage here" + ], + "traps": ["hydration-race", "single-datapoint"], + "source": [ + "objectui packages/app-shell/src/layout/InboxPopover.tsx (totalBadge = unreadTopics + pendingApprovalsCount; three tabs; per-group markGroupRead + global onMarkAllRead)", + "objectui packages/app-shell/src/layout/inboxGrouping.ts (groupNotifications — (topic,title) coalescing, #2765)", + "objectui packages/app-shell/src/layout/AppHeader.tsx (postMarkRead → POST /api/v1/notifications/read[/all]; poll GET /notifications?view=mine)", + "packages/runtime/src/route-ledger.ts (GET /notifications, POST /notifications/read, POST /notifications/read/all)", + "packages/runtime/src/domains/notifications.ts (markRead upserts sys_notification_receipt; empty/mis-keyed ids → 400)", + "ADR-0030 (sys_inbox_message L5 materialization + sys_notification_receipt); ADR-0103 (receipt object api-method lockdown)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — bell/InboxPopover: topic-based badge (#2765), (topic,title) coalescing, per-group + global mark-read on the wire, approvals tab, receipt persistence; cross-referenced with i18n + approvals notification items", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.shell-nav-personalization", + "title": "Shell nav personalization: sidebar collapse, pin/reorder, favorites, recents and header breadcrumbs each persist and stay navigable across reload", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "stock showcase nav (multiple objects/dashboards/reports so there is something to pin, reorder, favorite, and visit as recents)" + ] + }, + "steps": [ + "sidebar collapse: toggle the sidebar to icon (collapsed) mode; confirm nav items still NAVIGATE in icon mode; reload — the collapsed state persists (cookie sidebar_state, packages/components/src/ui/sidebar.tsx SIDEBAR_COOKIE_NAME)", + "nav pin/unpin: pin a nav item (UnifiedSidebar → useNavPins.togglePin → favorite id nav:<navId>, backed by FavoritesProvider/UserDataAdapter); it appears in the Pinned section; reload — the pin persists (syncs via UserDataAdapter; migrates the legacy objectui-nav-pins key); unpin removes it", + "drag-reorder: reorder nav items by drag (UnifiedSidebar useNavOrder, enableReorder / onReorder=handleReorder); reload — the order persists (localStorage per-app key)", + "favorites star/unstar: star an object/record as a favorite (useFavorites / FavoritesProvider); it appears in favorites; reload — persists; unstar removes it", + "recents rail: visit several records/objects/dashboards; the sidebar Recent section (collapsed by default, top 5 — useTrackRouteAsRecent → RecentItemsProvider, localStorage objectui-recent-items scoped by userId, hydrates UserDataAdapter) lists them; reload — recents survive", + "header record-trail breadcrumbs: on a record detail the AppHeader breadcrumb shows the app/section/record trail; click a crumb to navigate back to its level; reload — the trail rebuilds from the route", + "record which persistence each feature uses: pins/favorites/recents are UserDataAdapter-backed (cross-device sync), reorder + collapse are local (localStorage / cookie)" + ], + "acceptance": [ + { + "clause": "collapsing the sidebar to icon mode still NAVIGATES — clicking an icon routes correctly — and the collapsed state survives reload (cookie sidebar_state)", + "oracle": "screenshot", + "verify": "collapsed screenshot + a nav click that routes + the collapsed state present after reload", + "evidence": "collapsed screenshot + post-reload state + the routed click" + }, + { + "clause": "pin/unpin: a pinned nav item appears in the Pinned section and survives reload; unpin removes it (useNavPins → nav:<navId> favorite)", + "oracle": "dom", + "verify": "after a screenshot confirms render, assert the pinned item present pre- and post-reload; absent after unpin", + "evidence": "pre/post-reload DOM" + }, + { + "clause": "drag-reorder persists: the reordered nav order is restored after reload (useNavOrder localStorage)", + "oracle": "dom", + "verify": "the nav item order before reload equals the order after reload", + "evidence": "order before/after reload" + }, + { + "clause": "favorites star/unstar persists across reload", + "oracle": "dom", + "verify": "the starred item is present in favorites before and after reload; gone after unstar", + "evidence": "pre/post-reload favorites list" + }, + { + "clause": "the Recents rail lists recently-visited entities (top 5) and survives reload (RecentItemsProvider)", + "oracle": "dom", + "verify": "the visited entities appear in the Recent section after reload", + "evidence": "visit sequence + post-reload recents" + }, + { + "clause": "header record-trail breadcrumbs render on a record and each crumb navigates back to its level; the trail rebuilds after reload", + "oracle": "screenshot", + "verify": "breadcrumb screenshot + a crumb navigation that routes back + the trail present after reload", + "evidence": "breadcrumb screenshot + the crumb navigation" + } + ], + "negative": [ + "a collapsed sidebar whose icon items no longer navigate (dead icon mode) is a FAIL", + "a pin / favorite / reorder / recent that does NOT survive reload is a FAIL of its persistence contract", + "a breadcrumb crumb that is inert (does not navigate) is a FAIL", + "transitional emptiness right after navigation (empty nav/recents) must be ruled out by the screenshot-first protocol before any persistence FAIL is recorded" + ], + "traps": ["hydration-race", "shared-browser-tab"], + "source": [ + "objectui packages/app-shell/src/layout/UnifiedSidebar.tsx (useNavOrder drag-reorder localStorage; Recent section; applyPins)", + "objectui packages/app-shell/src/hooks/useNavPins.ts (togglePin → nav:<navId> favorite, MAX_PINS 20, UserDataAdapter-backed, legacy objectui-nav-pins migration)", + "objectui packages/app-shell/src/hooks/useFavorites.ts + context/FavoritesProvider.tsx (favorites state)", + "objectui packages/app-shell/src/context/RecentItemsProvider.tsx + hooks/useTrackRouteAsRecent.ts (recents — objectui-recent-items localStorage + UserDataAdapter hydrate)", + "objectui packages/app-shell/src/layout/AppHeader.tsx (breadcrumb record-trail)", + "objectui packages/components/src/ui/sidebar.tsx (SIDEBAR_COOKIE_NAME sidebar_state — collapse persistence)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — sidebar collapse/icon-mode, nav pin+reorder, favorites, recents rail, header breadcrumbs, each with its real persistence layer (cookie/localStorage/UserDataAdapter)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "platform-core.app-management-toggle", + "title": "App management: the launcher/App Switcher filters by active+hidden and isDefault drives post-login landing — but the AppManagementPage toggle is a client-only stub today", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a plain member (fresh runtime sign-up — member_default)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a writable package to author an app overlay into (POST /api/v1/packages) so the active/isDefault EFFECT can be exercised at the metadata layer — the seeded showcase/platform apps are read-only code packages (ADR-0070)" + ], + "knownGaps": [ + "AppManagementPage's Enable/Disable/Set-default/Delete/Bulk handlers are CLIENT-ONLY STUBS today (objectui apps/console/src/pages/system/AppManagementPage.tsx — every handler is a toast.success() + refresh() carrying a 'TODO: Replace with real API call when backend supports app management' marker; no request is issued and no metadata changes). So the page's toggle CANNOT prove the effect — the launcher/landing behavior is exercised at the metadata layer instead, and the stub is recorded as an expected-fail probe (RUNNER rule 3: a stub that reports success is the finding)" + ] + }, + "steps": [ + "establish the launcher filter is real: the App Switcher (objectui AppSwitcher.tsx) lists only apps with active !== false && hidden !== true. Confirm the Account app (hidden:true — account.app.ts) is ABSENT from the switcher while reachable via the avatar dropdown; Setup/Studio/showcase present", + "prove DISABLE-effect at the metadata layer (the UI toggle is a stub): author an app overlay with active:false into a writable package (POST /api/v1/packages + PUT /api/v1/meta/app/<name> active:false, publish) → confirm it leaves the App Switcher/launcher for end users (absent); flip active:true → it returns", + "prove DEFAULT→landing: the isDefault app drives post-login landing — confirm login lands on the current default app, and that changing which app is isDefault (at the metadata layer) changes the post-login landing route", + "member view: as a plain member the App Switcher shows only accessible apps (Setup/Studio absent by App.requiredPermissions); disabled/hidden apps also absent", + "open Setup → Apps → Applications (AppManagementPage) as admin; it lists configured apps with Active/Default badges and per-row controls", + "EXPECTED-FAIL / known-stub probe: click Disable on a seeded app in AppManagementPage → a success toast appears BUT no request is issued (empty network) and GET /api/v1/meta/app shows the app still active on reload. Record the toast-without-persistence; do NOT credit the app as disabled off the toast", + "same probe for Set-default and Delete: the toast fires, the metadata is unchanged on reload" + ], + "acceptance": [ + { + "clause": "the App Switcher lists only active, non-hidden apps: hidden apps (Account) and inactive apps are absent; active apps present (AppSwitcher.tsx activeApps = active !== false && hidden !== true)", + "oracle": "dom", + "verify": "after a screenshot confirms the switcher rendered, diff its entries against the served /meta/app list — Account (hidden) absent, active apps present", + "evidence": "switcher DOM vs the served app list" + }, + { + "clause": "disabling an app removes it from the launcher for end users — proven by setting active:false at the metadata layer (writable-package overlay), then confirming absence; re-enable returns it", + "oracle": "screenshot", + "verify": "before/after App Switcher screenshots bracketing the active:false and active:true meta writes", + "evidence": "before/after switcher screenshots + the meta writes" + }, + { + "clause": "the default app drives post-login landing: login lands on the isDefault app, and changing the default changes the landing route", + "oracle": "screenshot", + "verify": "post-login screenshots for two different isDefault choices land on different apps", + "evidence": "the two post-login screenshots" + }, + { + "clause": "a member sees only accessible apps in the switcher (Setup/Studio absent by requiredPermissions; disabled/hidden absent)", + "oracle": "dom", + "verify": "the member's switcher DOM excludes Setup/Studio and any disabled/hidden app", + "evidence": "the member switcher DOM" + }, + { + "clause": "AppManagementPage renders the Applications list with Active/Default badges and per-row controls", + "oracle": "screenshot", + "verify": "the page renders the app cards with the Active/Default/Inactive badges", + "evidence": "the page screenshot" + }, + { + "clause": "EXPECTED-FAIL / known-stub probe — AppManagementPage's Disable/Set-default/Delete are client-only stubs: clicking Disable issues NO request (empty network) and GET /api/v1/meta/app is UNCHANGED on reload, while a success toast is shown. A run records the stub and MUST NOT tick 'disabled for end users' off the toast", + "oracle": "network", + "verify": "the network trace shows no PATCH/DELETE on the click; /meta/app unchanged on reload; the toast fired (AppManagementPage TODO handlers)", + "evidence": "the absent request + the unchanged meta + the toast" + } + ], + "negative": [ + "ticking 'app disabled' off the AppManagementPage success toast is a FALSE PASS — the handler is a stub (no backend), the effect must be proven at the metadata/launcher layer", + "the App Switcher showing a hidden (Account) or inactive app to end users is a FAIL of the launcher filter", + "post-login landing that ignores isDefault is a FAIL" + ], + "traps": ["wrong-persona", "stale-console-bundle", "hydration-race"], + "source": [ + "objectui apps/console/src/pages/system/AppManagementPage.tsx (the stubbed Enable/Disable/Set-default/Delete/Bulk handlers — TODO 'when backend supports app management')", + "objectui packages/app-shell/src/layout/AppSwitcher.tsx (activeApps = active !== false && hidden !== true)", + "packages/platform-objects/src/apps/account.app.ts (hidden:true example)", + "packages/runtime/src/route-ledger.ts (PUT /meta/app/:name, POST /packages/:id/publish — the metadata layer that actually changes app active/default)", + "ADR-0048 (app package routing / launcher)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "initial — launcher active+hidden filter and isDefault→landing proven at the metadata layer, with the AppManagementPage toggle recorded as a client-only stub (expected-fail probe) rather than faked", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json new file mode 100644 index 0000000000..4b42df13a1 --- /dev/null +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -0,0 +1,3195 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "records-forms", + "title": "Records, lists, detail pages, forms", + "items": [ + { + "id": "records-forms.crud-roundtrip", + "title": "Create → read → update → delete a record through the console UI", + "since": "v15", + "status": "active", + "revision": 3, + "priority": "P0", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_account — writable standard object (sharingModel public_read_write), required name + status, format validations tax_id_format / billing_email_format (examples/app-showcase/src/data/objects/account.object.ts)" + ] + }, + "steps": [ + "sign in as seeded admin; open Accounts via its nav entry (/_console/apps/com.example.showcase/showcase_account)", + "click New; fill name with a distinctive value (os-qa-<runid>), industry, status=active; leave website empty; Save", + "capture the create request/response (expect POST /api/v1/data/showcase_account)", + "re-read server-side: GET /api/v1/data/showcase_account?$filter=[[\"name\",\"=\",\"os-qa-<runid>\"]] — field-by-field vs the submitted payload", + "open the record detail; edit exactly one field (annual_revenue); Save; capture the PATCH /api/v1/data/showcase_account/<id>; re-read the full row via GET /api/v1/data/showcase_account/<id>", + "open the record's History tab (screenshot first, then read entries)", + "clone the record: POST /api/v1/data/showcase_account/<id>/clone (optionally with {overrides:{name:'os-qa-<runid>-clone'}}); capture the 201 result (new id ≠ source, sourceId, record); re-read the clone via GET /api/v1/data/showcase_account/<new id>", + "RLS probe on clone: as a persona WITHOUT read on the source row (a permission-zoo persona, or a non-owner under owner-RLS), POST the same clone route and capture the refusal — expect 404 RECORD_NOT_FOUND, never a silent duplicate (record blocked(fixture) if no non-reading persona is bound on this boot)", + "delete via the row/detail action; confirm the dialog; re-read via the filtered GET", + "reload the list view and confirm final state" + ], + "acceptance": [ + { + "clause": "create returns success with an id and the API re-read shows every submitted value verbatim (no silent coercion/loss on any field)", + "oracle": "api", + "verify": "field-by-field diff of the POST payload vs the filtered GET re-read; the empty optional (website) stays empty, not defaulted", + "evidence": "create payload + re-read JSON" + }, + { + "clause": "the update persists ONLY the edited field — untouched fields byte-identical across before/after full-row reads", + "oracle": "api", + "verify": "diff of GET /api/v1/data/showcase_account/<id> before and after the single-field annual_revenue edit", + "evidence": "the two full-row reads" + }, + { + "clause": "delete removes the row authoritatively — the filtered API re-read returns 0 rows and the reloaded list no longer shows it", + "oracle": "api", + "verify": "GET ...?$filter=[[\"name\",\"=\",\"os-qa-<runid>\"]] returns total 0 post-delete", + "evidence": "the empty read + post-reload screenshot" + }, + { + "clause": "the list reflects each mutation after a full reload with correct display values (grid repaint is NOT the oracle — the reload is)", + "oracle": "screenshot", + "verify": "post-reload screenshots of the list at create and at delete", + "evidence": "screenshots" + }, + { + "clause": "record History reflects the create and the update with display values (option labels, localized dates, real actor), not raw audit payloads or phantom value→null rows", + "oracle": "dom", + "verify": "after a screenshot confirms the History tab rendered, read its entries — same contract objectui e2e/live/record-history-display.spec.ts pins on showcase Project", + "evidence": "screenshot + entries" + }, + { + "clause": "unicode round-trips: a create with a CJK name (e.g. os-qa-<runid>-华宁) reads back byte-identical and is findable via list quick-search", + "oracle": "api", + "verify": "filtered GET returns the CJK name unmangled; $search finds it (the seed's 华宁科技 proves the pattern — ADR-0061)", + "evidence": "re-read JSON + search response" + }, + { + "clause": "clone (POST /data/:object/:id/clone, gated by enable.clone default-on) returns 201 with a NEW id and the source's field VALUES copied, but engine-owned columns (id, audit, autonumber, formula, summary) and readonly columns (e.g. approval_status) RE-DERIVED not carried, and the clone is owned by the CLONER — not the source's owner", + "oracle": "api", + "verify": "the 201 result carries {id (new ≠ sourceId), sourceId, record}; field-by-field diff shows business values copied and system/readonly columns re-derived (#3043 CLONE_STRIP_FIELDS + stripReadonlyForInsert); owner_id resolves to the signed-in cloner (the clone is a create in the caller's context — packages/metadata-protocol/src/protocol.ts cloneData)", + "evidence": "clone response + source-vs-clone field diff + owner_id read" + }, + { + "clause": "clone is RLS-gated: the source is fetched in the caller's context (engine.findOne with context), so cloning a record the caller cannot SEE is refused with 404 RECORD_NOT_FOUND — never a silent duplicate of an invisible row", + "oracle": "api", + "verify": "as a persona without read on the source row, POST the clone route → 404 RECORD_NOT_FOUND and no new row lands (recordNotFoundError from the null findOne); an enable.clone:false object refuses with 403 CLONE_DISABLED", + "evidence": "the RLS refusal + a post-attempt count showing no new row" + } + ], + "negative": [ + "save with required status empty → the form blocks with a named field error AND no row is created (filtered API count stays 0) — a silent success is a FAIL", + "a clone that carries over the source's id / audit columns / an autonumber / a readonly approval_status instead of re-deriving them is a FAIL (#3043 — a clone must not mint an already-approved record); a clone of an RLS-invisible source that SUCCEEDS (200/201 with a new row) is a FAIL — the findOne runs in the caller's context precisely to refuse it", + "a direct API POST missing required status → 400 VALIDATION_FAILED with fields[] carrying code 'required' (server enforces, not just the form — packages/objectql/src/validation/record-validator.ts)", + "a create with tax_id violating the tax_id_format rule → named validation error, never a silently-stored bad value" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "source": [ + "dogfood-verification skill §3", + "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)", + "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" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — standing P0 smoke", + "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-08", + "change": "added the /data/:object/:id/clone clauses (new id + copied field values, cloner ownership, engine/readonly column re-derivation, RLS-invisible source refused RECORD_NOT_FOUND, enable.clone gate); inline-edit two-surface behavior placed in concurrent-edit-conflict with a cross-ref here", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.field-type-matrix", + "title": "Field-type matrix: every FieldTypeSchema member renders its widget, accepts a valid value, round-trips over HTTP, and rejects invalid input", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_field_zoo — one field of (almost) every FieldType (examples/app-showcase/src/data/objects/field-zoo.object.ts); seeded rows 'Specimen — Full' and 'Specimen — Minimal'" + ], + "knownGaps": [ + "summary is NOT on the zoo (a roll-up needs a child object and the zoo is a leaf) — verify it on its real carriers showcase_invoice.total and showcase_expense_report.total_amount/approved_amount instead", + "f_user / f_users / f_owner cannot be seeded or written on a fresh boot with no signed-up users (sys_user rows come from sign-up, not seeds) — assign after creating a user, or record blocked(fixture)", + "f_secret writes fail closed unless an ICryptoProvider is registered (ADR-0100) — the seed deliberately omits it; on a stock boot verify the masked-read contract via the dogfood pin, not a raw UI write" + ] + }, + "variants": [ + "text", + "textarea", + "email", + "url", + "phone", + "password", + "secret", + "markdown", + "html", + "richtext", + "number", + "currency", + "percent", + "date", + "datetime", + "time", + "boolean", + "toggle", + "select", + "multiselect", + "radio", + "checkboxes", + "lookup", + "master_detail", + "tree", + "user", + "image", + "file", + "avatar", + "video", + "audio", + "formula", + "summary", + "autonumber", + "composite", + "repeater", + "record", + "location", + "address", + "code", + "json", + "color", + "rating", + "slider", + "signature", + "qrcode", + "progress", + "tags", + "vector" + ], + "steps": [ + "run the pinned HTTP round-trip matrix: pnpm --filter @objectstack/dogfood exec vitest run test/field-zoo-roundtrip.dogfood.test.ts (write vectors + expected read shapes live in test/field-zoo.matrix.ts)", + "boot showcase isolated; open Field Zoo (/_console/apps/com.example.showcase/showcase_field_zoo) and open 'Specimen — Full' in edit mode; screenshot the form before reading DOM", + "enumerate the rendered control for every f_* field and build a variant→widget table (date→date input, color→color input, richtext→editor, select/multiselect→pickers, image/file→upload, code→code editor, location/address→structured inputs, rating/slider/progress→their own controls, autonumber/formula→read-only)", + "through the form, change one representative value per family (a select, an array type, a temporal, a structured JSON), Save, and capture the PATCH", + "re-read the record via GET /api/v1/data/showcase_field_zoo/<id> and diff against the submitted values (arrays compared as sets)", + "POST an out-of-set select value (f_select: 'not-a-value') directly to /api/v1/data/showcase_field_zoo and capture the refusal", + "POST f_lookup with a fabricated account id and capture the refusal (#4441 dangling-reference gate)", + "verify f_formula and f_autonumber materialized server-side on a created row (formula = f_number × f_percent / 100)" + ], + "acceptance": [ + { + "clause": "every authorable field type in the matrix survives a real HTTP POST → GET round-trip with its declared value shape (arrays as sets; JSON object types as objects, not stringified)", + "oracle": "test", + "verify": "pnpm --filter @objectstack/dogfood exec vitest run test/field-zoo-roundtrip.dogfood.test.ts — green, with any it.fails (xfail) rows reported as the known type-fidelity gaps they are", + "evidence": "test run output" + }, + { + "clause": "PER-VARIANT: each of the 49 FieldTypeSchema members renders its real widget on the Specimen — Full form (not a generic text input), with per-variant evidence recorded in the variant→widget table", + "oracle": "dom", + "verify": "after the screenshot confirms render, match each f_* control against its declared type; every variant row in the table carries its own observed control + screenshot crop", + "evidence": "form screenshot + the 49-row variant→widget table" + }, + { + "clause": "constrained types reject invalid input server-side with a named error: out-of-set select → VALIDATION_FAILED with fields[].code 'invalid_option'; missing required name → code 'required'", + "oracle": "api", + "verify": "direct POSTs with the bad payloads return 400-class VALIDATION_FAILED envelopes naming the field; the row count does not grow", + "evidence": "refusal responses + before/after counts" + }, + { + "clause": "credential types mask on read: f_secret and f_password never echo plaintext — reads return the SECRET_MASK sentinel", + "oracle": "api", + "verify": "the dogfood matrix 'masked' checks (kind: 'masked' in test/field-zoo.matrix.ts) pass; any GET of the record shows the mask, not the written value", + "evidence": "test output + a raw GET excerpt" + }, + { + "clause": "computed/system types materialize without being written: f_autonumber is server-assigned and non-null; f_formula reads f_number × f_percent / 100 (matrix vector: 42 × 75 / 100 = 31.5)", + "oracle": "api", + "verify": "create via the matrix suite or by hand and read both fields back; the formula value matches the arithmetic", + "evidence": "the read JSON" + }, + { + "clause": "relational types (lookup / master_detail / tree) store a real reference id verbatim and refuse a dangling one", + "oracle": "api", + "verify": "matrix REFERENCE_TARGETS rows (showcase_account / showcase_project / showcase_category) round-trip their created ids; a fabricated id is rejected (#4441)", + "evidence": "test output + the dangling-id refusal" + }, + { + "clause": "summary is verified on its real carriers: showcase_invoice.total sums its lines server-side; showcase_expense_report shows the summaryOperations.filter variant (approved_amount ≠ total_amount on seeded EXP-2001)", + "oracle": "api", + "verify": "GET the seeded invoices/expense reports and check the roll-up columns against the seeded line arithmetic (EXP-2001: total 1500.50, approved 960 — seed comments carry the expected values)", + "evidence": "the reads + the seed-derived expected table" + } + ], + "negative": [ + "an out-of-set select value accepted with 200 is a FAIL (the server, not the picker, is the boundary)", + "a lookup/master_detail/tree write pointing at a nonexistent row accepted with 200 is a FAIL (#4441 closed exactly this hole)", + "any GET returning f_secret/f_password plaintext is a FAIL regardless of what the form shows" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle" + ], + "automated": { + "kind": "api", + "ref": "packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts vectors, field-zoo-value-shape.test.ts contract)" + }, + "source": [ + "packages/spec/src/data/field.zod.ts (FieldType enum — 49 members, listed exhaustively in variants)", + "examples/app-showcase/src/data/objects/field-zoo.object.ts", + "examples/app-showcase/src/data/seed/index.ts (Specimen rows; expense/invoice roll-up expectations)", + "packages/qa/dogfood/test/field-zoo.matrix.ts", + "objectui: e2e/live/summary-rollup.spec.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test variant matrix over FieldTypeSchema, pinned to the dogfood HTTP round-trip suite", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly", + "ref": "claude/platform-test-checklist-ocwugl" + } + ], + "enumSource": { + "file": "packages/spec/src/data/field.zod.ts", + "export": "FieldType", + "expect": 49 + } + }, + { + "id": "records-forms.list-view-capabilities", + "title": "List-view capability matrix: filter, search, sort, pagination, saved views, inline edit, export, visualization switcher, bulk/row actions, conditional formatting", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_task with 10 seeded tasks (2 in_progress) and the saved views in examples/app-showcase/src/ui/views/task.view.ts (in_progress/urgent/done, tabular with string sort, grid with inlineEdit, bulk_actions, legacy_row_actions)", + "showcase_field_zoo views for conditional formatting + bulk-action gating (examples/app-showcase/src/ui/views/field-zoo.view.ts)", + "CJK seeds for search recall (account 华宁科技, contact 张伟)" + ] + }, + "variants": [ + "saved-view-filter", + "quick-search", + "sort", + "server-pagination", + "saved-view-switcher", + "inline-edit", + "export", + "visualization-switcher", + "bulk-actions", + "row-actions", + "conditional-formatting" + ], + "steps": [ + "open /_console/apps/com.example.showcase/showcase_task and screenshot the default All Tasks grid", + "switch to the in_progress saved view; capture the data request — expect $filter carrying the stored rule as an ObjectQL AST ([[\"status\",\"equals\",\"in_progress\"]]) and a 200; the count bar must read 2", + "quick-search 'huaning' on Accounts (or '张' on Contacts); capture the $search request and the hits (pinyin + CJK recall, ADR-0061)", + "open the tabular saved view (string-form sort 'estimate_hours desc' — the objectui#2601 fixture); confirm it renders and the request carries the sort", + "page a list past page 1; capture $top/$skip on every data request; confirm no unpaged full fetch", + "in the grid saved view (inlineEdit: true), edit one cell in place; save; capture the PATCH; re-read the row via API", + "on in_progress, export each declared format (csv, xlsx, json); capture the requests and files", + "open the visualization switcher on All Tasks; enumerate the offered types; switch through each", + "on bulk_actions, select rows and run showcase_mark_done (per-record) and showcase_recalc_selection (aggregate — ONE dispatch carrying params._selectedIds); on legacy_row_actions confirm both named row actions resolve to live menu entries", + "on Field Zoo, confirm the conditionalFormatting row highlight applies to Specimen — Full (f_lookup set) and not Specimen — Minimal" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: every listed capability variant is exercised and carries its own captured evidence (request trace or screenshot) — no variant ticked by association", + "oracle": "network", + "verify": "one evidence artifact per variant in the run record, keyed by variant name", + "evidence": "per-variant trace/screenshot set" + }, + { + "clause": "a saved view's stored filter reaches $filter as an ObjectQL AST, the server answers 200, and the filtered count is server-computed (2 of 10 for in_progress)", + "oracle": "network", + "verify": "captured GET /api/v1/data/showcase_task?...$filter=[[\"status\",\"equals\",\"in_progress\"]] → 200; count bar matches the response total", + "evidence": "request/response trace + screenshot" + }, + { + "clause": "quick-search issues a server-side $search (not client filtering) and finds the CJK account via pinyin ('huaning' → 华宁科技)", + "oracle": "api", + "verify": "the captured request carries $search; the response contains the CJK row; the same query direct against GET /api/v1/data/showcase_account reproduces it", + "evidence": "request trace + response JSON" + }, + { + "clause": "paging issues $top/$skip requests; the full set is never fetched; a walked page sequence visits every row exactly once (deterministic paging)", + "oracle": "network", + "verify": "page 1 = $top=N; page 2 = $top=N&$skip=N; union of pages has no duplicate and no missing id", + "evidence": "the request URLs + the id-union check" + }, + { + "clause": "string-form sort ('estimate_hours desc') renders without error and the rows come back server-ordered (the objectui#2601 crash fixture stays green)", + "oracle": "network", + "verify": "the tabular view request succeeds; response row order is by estimate_hours descending", + "evidence": "trace + first-page rows" + }, + { + "clause": "inline edit persists through the API — the PATCH carries only the edited cell and a post-reload re-read shows it, with untouched fields unchanged", + "oracle": "api", + "verify": "diff full-row reads before/after the cell edit (same contract objectui e2e/live/inline-edit-polish-2572.spec.ts pins on Project)", + "evidence": "the two reads + the PATCH body" + }, + { + "clause": "export offers exactly the declared formats (csv, xlsx, json on in_progress) and the exported rows equal the FILTERED set, not the whole table", + "oracle": "network", + "verify": "the export menu lists the three exportOptions; each downloaded file contains the 2 in_progress rows", + "evidence": "menu screenshot + the three files" + }, + { + "clause": "the visualization switcher offers exactly the whitelisted six (grid, kanban, gallery, calendar, timeline, gantt — appearance.allowedVisualizations) and each re-renders the SAME task records", + "oracle": "dom", + "verify": "after screenshot, enumerate the dropdown entries; switch to each and confirm records render (map/chart are named views, correctly NOT in the switcher)", + "evidence": "dropdown screenshot + one screenshot per visualization" + }, + { + "clause": "bulk actions dispatch correctly by mode: per-record actions issue one dispatch per selected record; the aggregate def (showcase_recalc_selection, execution: 'aggregate') issues ONE request carrying every selected id in params._selectedIds", + "oracle": "network", + "verify": "count the captured POSTs against the selection size for each mode", + "evidence": "network trace of both runs" + }, + { + "clause": "legacy string rowActions resolve against the object's declared actions — showcase_recalc_estimate is a live entry (not a dead menu item) and showcase_quick_view appears exactly once (no dead duplicate)", + "oracle": "dom", + "verify": "after screenshot, open a row menu on the legacy_row_actions view; click each entry and confirm it dispatches (objectui#2960 contract)", + "evidence": "menu screenshot + dispatch traces" + } + ], + "negative": [ + "a saved view whose filter is refused (400 INVALID_FILTER) while the grid silently shows ALL rows unfiltered is a FAIL — the objectui#3431 regression shape; zero rows with a captured refusal is the honest symptom, unfiltered rows is the lie", + "userFilters leaking onto an OBJECT list view is a FAIL — ADR-0053 suppresses them there by design (filter elements belong to interface pages; objectui warns since #2220)", + "an export that returns the unfiltered table for a filtered view is a FAIL even though a file downloaded" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/saved-view-filter.spec.ts, e2e/live/user-filters.spec.ts, e2e/live/inline-edit-polish-2572.spec.ts; packages/qa/dogfood/test/showcase-search.dogfood.test.ts" + }, + "source": [ + "examples/app-showcase/src/ui/views/task.view.ts (saved views, sort string form, inlineEdit, exportOptions, bulk/row actions, allowedVisualizations)", + "examples/app-showcase/src/ui/views/field-zoo.view.ts (conditionalFormatting, gated bulk actions)", + "packages/spec/src/ui/view.zod.ts (UserActionsConfigSchema, AppearanceConfigSchema/VisualizationTypeSchema)", + "packages/spec/src/data/pagination-conformance.ts (deterministic paging property)", + "examples/app-showcase/src/data/seed/index.ts (10 tasks / 2 in_progress; CJK rows)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test capability matrix for list surfaces, pinned to the objectui live specs where they exist", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.view-type-gallery", + "title": "View-type gallery: every ListViewSchema visualization renders seeded records", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_task views covering 8 types (examples/app-showcase/src/ui/views/task.view.ts: grid, board/kanban, cards/gallery, calendar, timeline, gantt, map, chart)", + "showcase_business_unit 'Organization Chart' for the 9th type, tree (examples/app-showcase/src/ui/views/business-unit.view.ts)", + "seeds sized to feed every view: 10 tasks across all 5 kanban columns with due/start/end/created dates and locations (src/data/seed/index.ts)" + ], + "knownGaps": [ + "task 'cover' (gallery coverField) is declared but deliberately unseeded (#4891 / ADR-0104 — a managed sys_file cannot honestly be seeded); the gallery renders coverless cards by design — do not fail the gallery variant on missing covers, upload one to prove the cover path" + ] + }, + "variants": [ + "grid", + "kanban", + "gallery", + "calendar", + "timeline", + "gantt", + "map", + "chart", + "tree" + ], + "steps": [ + "open /_console/apps/com.example.showcase/showcase_task and walk its named views: Grid, Board (Kanban), Cards (Gallery), Calendar, Activity Timeline, Schedule (Gantt), Work Locations (Map), Hours by Status (Chart)", + "on each: wait for render, screenshot FIRST, then read the DOM for the visualization's own structure (kanban columns, calendar cells, gantt bars, map markers, chart SVG)", + "on the kanban board, verify the groupByField columns match the 5 task statuses and every column is populated; check the summarizeField (estimate_hours) totals against an API aggregate", + "on calendar/timeline/gantt, spot-check 2 records' placement against their seeded due_date / created_at / start_date+end_date via API reads", + "on the chart view, confirm a real SVG is drawn from dataset showcase_task_metrics (not an empty canvas or a single bar from thin data)", + "open /_console/apps/com.example.showcase/showcase_business_unit and its Organization Chart view; verify the tree renders the seeded parent hierarchy", + "capture one screenshot per variant for the per-variant evidence set" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: each of the 9 ListViewSchema types renders its REAL visualization (not a grid fallback) with seeded records, with its own screenshot in the evidence set", + "oracle": "screenshot", + "verify": "one screenshot per variant showing the visualization's characteristic structure; a variant that silently fell back to grid is a FAIL for that variant", + "evidence": "9 screenshots keyed by variant" + }, + { + "clause": "kanban groups by status with every seeded column populated, and the summarizeField per-column totals agree with a direct API aggregate of estimate_hours", + "oracle": "api", + "verify": "compare column headers/totals against GET /api/v1/data/showcase_task grouped client-side from the raw rows", + "evidence": "board screenshot + the aggregate check" + }, + { + "clause": "temporal views place records by their true date fields: calendar by due_date, timeline by created_at, gantt bars spanning start_date→end_date with progressField rendered", + "oracle": "api", + "verify": "for 2 sampled tasks, the placement matches the API-read dates", + "evidence": "screenshots + the two API reads" + }, + { + "clause": "map renders a marker per task with a location value; chart draws a non-empty SVG bound to the showcase_task_metrics dataset", + "oracle": "dom", + "verify": "after screenshots confirm render, count markers vs rows with location; assert svg element with plotted marks exists", + "evidence": "screenshots + DOM excerpts" + }, + { + "clause": "tree renders the seeded business-unit hierarchy (parent references as nesting), expandable without error", + "oracle": "dom", + "verify": "after screenshot, the rendered nesting matches parent fields read via GET /api/v1/data/showcase_business_unit", + "evidence": "screenshot + API read" + }, + { + "clause": "no variant throws a pageerror or renders an empty <main> (the automated smoke covers the page-level render for these surfaces)", + "oracle": "test", + "verify": "pnpm -C examples/app-showcase test:smoke — the All Views / Task Board / Calendar / Gallery / Schedule / Timeline / Work Map SURFACES stay green", + "evidence": "test run output" + } + ], + "negative": [ + "a visualization that renders as a plain grid (fallback) while its type claims kanban/calendar/gantt/etc. is a FAIL for that variant — 'it rendered something' is not the oracle", + "a chart that renders from a single datapoint proves little — note the weakness in evidence rather than ticking silently (single-datapoint trap)" + ], + "traps": [ + "hydration-race", + "single-datapoint", + "wrong-panel" + ], + "automated": { + "kind": "e2e", + "ref": "examples/app-showcase/e2e/showcase-smoke.spec.ts (page-level render for the view surfaces)" + }, + "source": [ + "packages/spec/src/ui/view.zod.ts (ListViewSchema type enum: grid|kanban|gallery|calendar|timeline|gantt|map|chart|tree)", + "examples/app-showcase/src/coverage.ts (LIST_VIEW_TYPES — the 8 the task object demonstrates)", + "examples/app-showcase/src/ui/views/task.view.ts", + "examples/app-showcase/src/ui/views/business-unit.view.ts (tree)", + "examples/app-showcase/src/data/seed/index.ts (view-feeding seed shape)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test view-type gallery derived from the spec's own enum, 8 types on task + tree on business-unit", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.form-view-gallery", + "title": "Form-view gallery: every FormViewSchema layout type renders and submits", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_task formViews: edit (simple), tabbed, wizard, split, quick (drawer) — examples/app-showcase/src/ui/views/task.view.ts" + ], + "knownGaps": [ + "the spec's 6th form type, 'modal', is not authored anywhere in the showcase (coverage.ts FORM_VIEW_TYPES deliberately lists 5) — record the modal variant blocked(fixture) rather than ticking or silently skipping it" + ] + }, + "variants": [ + "simple", + "tabbed", + "wizard", + "split", + "drawer", + "modal" + ], + "steps": [ + "open a task record and its 'edit' (simple) form view; screenshot; verify the single 2-column section with the declared field order", + "open the tabbed form view; verify the three tabs (Overview / Schedule / Details) and that switching tabs preserves entered values", + "open the wizard form view; walk Basics → Assignment → Schedule with next/back; verify back preserves values and submit happens ONCE at the end", + "open the split form view; verify primary/secondary panes render their assigned sections side-by-side", + "trigger the quick (drawer) form; verify it renders as a side panel over the list with the 3 declared fields", + "on the simple form, set priority to 'urgent' and verify the notes field appears (FormField.visibleWhen CEL); set it back and verify notes hides", + "save an edit through each rendered variant; capture each save request; re-read via GET /api/v1/data/showcase_task/<id>" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: each authored form type renders its own layout mechanics (sections / tabs / steps / panes / side panel), with a screenshot per variant; the unauthored 'modal' variant is recorded blocked(fixture), never ticked", + "oracle": "screenshot", + "verify": "one screenshot per variant showing the characteristic layout; wizard shows step chrome, split shows two panes, drawer overlays the list", + "evidence": "per-variant screenshots" + }, + { + "clause": "the wizard enforces step order and issues exactly ONE save at the end (no per-step writes)", + "oracle": "network", + "verify": "network trace across the walk shows a single POST/PATCH at final submit", + "evidence": "the trace" + }, + { + "clause": "view-level visibleWhen works live: notes renders only while priority == 'urgent', full-width via span 'full'", + "oracle": "dom", + "verify": "after screenshot, toggle priority and assert notes mounts/unmounts (same family as objectui e2e/live/field-conditional-rules.spec.ts)", + "evidence": "before/after screenshots" + }, + { + "clause": "every save through every variant persists — API re-read shows the edited value and untouched fields unchanged", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_task/<id> after each variant's save; diff against pre-save read", + "evidence": "the reads" + }, + { + "clause": "tab/step navigation never loses entered-but-unsaved values (switching tabs or going back a wizard step preserves the draft)", + "oracle": "dom", + "verify": "enter a distinctive value, navigate away and back within the form, read the control value", + "evidence": "screenshots at each hop" + } + ], + "negative": [ + "a wizard that lets Next past a required title with no named field error — or that silently writes per step — is a FAIL", + "counting the modal variant as passed because the other five rendered is a FAIL: it must be recorded blocked(fixture) with this item cited" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "source": [ + "packages/spec/src/ui/view.zod.ts (FormViewSchema type enum: simple|tabbed|wizard|split|drawer|modal)", + "examples/app-showcase/src/coverage.ts (FORM_VIEW_TYPES — the 5 authored)", + "examples/app-showcase/src/ui/views/task.view.ts (formViews incl. visibleWhen on notes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test form-view gallery derived from the spec enum; modal recorded as a standing fixture gap", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.conditional-rules-header", + "title": "Header-level conditional rules: visibleWhen / requiredWhen / readonlyWhen on invoice fields, enforced on BOTH ends", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_invoice conditional fields (examples/app-showcase/src/data/objects/invoice.object.ts): issued_on requiredWhen status in ['sent','paid']; tax_rate readonlyWhen status=='paid'; paid_on visibleWhen+requiredWhen status=='paid'; userActions edit disabledWhen / delete visibleWhen on paid", + "seeded invoices: INV-1002 (draft), INV-1001 (sent), INV-1003 (paid)" + ] + }, + "steps": [ + "open /_console/apps/com.example.showcase/showcase_invoice; open INV-1002 (draft) in its form", + "flip status draft → sent; observe issued_on gain a required marker; attempt save with issued_on empty; capture the block", + "set issued_on; save; re-read via GET /api/v1/data/showcase_invoice/<id>", + "direct API negative: POST /api/v1/data/showcase_invoice with status 'sent' and no issued_on; capture the refusal", + "open INV-1003 (paid): verify paid_on is visible and required, tax_rate renders read-only", + "direct API: PATCH /api/v1/data/showcase_invoice/<INV-1003 id> with tax_rate: 99; then GET the row and compare tax_rate to its pre-PATCH value", + "on the invoice LIST, inspect INV-1003's row actions: Edit visible but disabled, Delete absent; compare a draft row's untouched menu", + "on INV-1002 (draft), verify paid_on is NOT rendered at all" + ], + "acceptance": [ + { + "clause": "requiredWhen reacts live in the form: leaving Draft marks issued_on required and blocks submit with a named field error while empty", + "oracle": "dom", + "verify": "after screenshot, assert the required marker + the field-level error on blocked submit (pinned by objectui e2e/live/field-conditional-rules.spec.ts and required-when-submit.spec.ts)", + "evidence": "screenshots + the blocked submit" + }, + { + "clause": "requiredWhen is enforced server-side: a direct POST with status 'sent' and no issued_on → VALIDATION_FAILED naming issued_on with code 'required' — the form is not the boundary", + "oracle": "api", + "verify": "the direct POST returns the 400-class envelope; invoice count for the test name stays 0", + "evidence": "refusal response + count check" + }, + { + "clause": "readonlyWhen locks the client AND the server drops the change: tax_rate on a paid invoice renders read-only, and a direct PATCH to it is silently DISCARDED — the persisted value must be unchanged on re-read (stripReadonlyWhenFields semantics: keep, not reject)", + "oracle": "api", + "verify": "GET before, PATCH tax_rate 99, GET after — before == after; note: a 200 on the PATCH is expected, the ORACLE is the unchanged re-read", + "evidence": "the two reads + the PATCH" + }, + { + "clause": "visibleWhen is honored: paid_on is absent from the draft form and present (and required) on the paid form", + "oracle": "dom", + "verify": "after screenshots of both forms, assert paid_on mounted only on paid", + "evidence": "both screenshots" + }, + { + "clause": "per-record row-action gating follows the same CEL truth: on paid rows Edit is visible-but-disabled (disabledWhen) and Delete is hidden (visibleWhen); draft rows keep the full menu", + "oracle": "dom", + "verify": "after screenshot, read both rows' action menus (objectui#2614; pinned by objectui e2e/live/list-row-action-cel.spec.ts)", + "evidence": "both row-menu screenshots" + }, + { + "clause": "the client and server evaluate ONE rule, not two: the same predicate that blocked the form blocks the API, and the field the client locked is the field the server strips", + "oracle": "api", + "verify": "cross-check clauses 1↔2 and 3: no case where the form blocks but the API accepts (or vice versa)", + "evidence": "the paired form + API results" + } + ], + "negative": [ + "a direct API POST of a 'sent' invoice without issued_on that succeeds is a FAIL (client-only enforcement)", + "a PATCH to tax_rate on a paid invoice whose new value PERSISTS is a FAIL — silent acceptance of a locked field is the defect this rule exists to stop", + "a Delete affordance on a paid invoice row is a FAIL even if clicking it would error later" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/field-conditional-rules.spec.ts, e2e/live/required-when-submit.spec.ts, e2e/live/list-row-action-cel.spec.ts" + }, + "source": [ + "examples/app-showcase/src/data/objects/invoice.object.ts (the B2 rules + userActions gating, with server-semantics comments)", + "packages/spec/src/data/field.zod.ts (requiredWhen/readonlyWhen/visibleWhen authoring surface)", + "packages/objectql/src/validation/rule-validator.ts (enforcement site)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test both-ends contract for header-level conditional rules on the seeded invoices", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.conditional-rules-grid", + "title": "Grid-level conditional rules: row-scoped requiredWhen and parent-scoped readonlyWhen in the inline line-item grid", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_invoice_line rules (examples/app-showcase/src/data/objects/invoice.object.ts): description requiredWhen record.quantity >= 100 (ROW-scoped); product/quantity/unit_price readonlyWhen parent.status == 'paid' (PARENT-scoped); receipt Field.file upload cell (objectui#2360)", + "a draft invoice with editable lines (INV-1002) and a paid one (INV-1003)" + ] + }, + "steps": [ + "open INV-1002 (draft) — the inline Line Items grid renders via inlineEdit: 'grid'", + "on one line, raise quantity to 120 with description empty; observe the description cell flag required ON THAT ROW; screenshot", + "confirm a second line with quantity 1 stays unflagged and editable (per-row evaluation)", + "attempt to save the batch with the flagged description still empty; capture the block; then fill it and save; capture the batch request", + "server-side negative: submit the same shape directly to the API (line with quantity 120, no description) and capture the refusal", + "flip the header status to paid and save; observe product/quantity/unit_price cells lock; flip back to sent/draft and observe them unlock (live parent-scope re-evaluation)", + "on a draft line, upload a file into the receipt cell; save the batch; re-read the line via API and confirm the stored managed-file reference" + ], + "acceptance": [ + { + "clause": "row-scoped requiredWhen flags the cell per row: only the row crossing quantity >= 100 is marked, and the batch save is blocked with a named cell error while its description is empty", + "oracle": "dom", + "verify": "after screenshot, assert the required flag on row 1 and its absence on row 2; blocked save shows the error (pinned by objectui e2e/live/grid-conditional-rules.spec.ts)", + "evidence": "screenshots + blocked save" + }, + { + "clause": "the same rule is enforced on the server write path: a direct batch write with the violating line → VALIDATION_FAILED naming description; no partial rows land", + "oracle": "api", + "verify": "the direct submit returns the refusal envelope; line count for the invoice is unchanged", + "evidence": "refusal + before/after line reads" + }, + { + "clause": "parent-scoped readonlyWhen re-evaluates live against the header record: setting status to paid locks the three cells, reverting unlocks them — without a page reload", + "oracle": "dom", + "verify": "after screenshots at each state, assert cell editability (pinned by objectui e2e/live/grid-parent-rules.spec.ts)", + "evidence": "lock/unlock screenshots" + }, + { + "clause": "the receipt upload cell stores a real managed file: the API re-read of the line carries the sys_file reference and the file is retrievable", + "oracle": "api", + "verify": "GET the line post-save; the receipt field holds the file id, not an inline blob (ADR-0104 stored form; pinned by objectui e2e/live/grid-file-upload.spec.ts)", + "evidence": "line read + fetched file" + }, + { + "clause": "the saved batch persists: reloading the invoice shows the edited lines with their values, and untouched sibling lines byte-identical", + "oracle": "api", + "verify": "diff all lines before/after the save; only the edited line changed", + "evidence": "the before/after reads" + } + ], + "negative": [ + "a batch save that succeeds with quantity 120 and empty description is a FAIL on both the client clause and the server clause", + "cells still editable on a paid invoice (or still locked after reverting) is a FAIL — the parent scope must re-evaluate live" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/grid-conditional-rules.spec.ts, e2e/live/grid-parent-rules.spec.ts, e2e/live/grid-file-upload.spec.ts" + }, + "source": [ + "examples/app-showcase/src/data/objects/invoice.object.ts (row/parent-scoped rules on the line object; ADR-0036 / #1581)", + "objectui: e2e/live/grid-conditional-rules.spec.ts, grid-parent-rules.spec.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test grid conditional rules split from the header item (different scopes, different pins)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.cascading-options", + "title": "Cascading and gated select options: dependsOn + per-option visibleWhen, client narrows / server rejects", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "non-admin user (sign one up — sys_user rows cannot be seeded)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_cascade (examples/app-showcase/src/data/objects/cascading-select.object.ts): province dependsOn ['country'] with per-option visibleWhen (cn → zj/gd, us → ca/tx); tier option 'restricted' gated on 'admin' in current_user.positions; sharingModel public_read_write", + "showcase_invoice.contact dependsOn ['account'] — the dependent LOOKUP twin (contact picker scoped to the chosen account's people; seed gives Northwind 26 contacts, Contoso 2, Fabrikam 1)" + ] + }, + "steps": [ + "open a New Cascading Select form (/_console/apps/com.example.showcase/showcase_cascade or its nav entry)", + "verify province is gated (empty/disabled) until a country is chosen", + "pick country=cn; enumerate province options (expect exactly Zhejiang, Guangdong); switch to us; re-enumerate (California, Texas) and verify a previously chosen cn province CLEARED", + "as admin, enumerate tier options (Standard + Restricted); save a record with tier=restricted", + "direct API negative: POST /api/v1/data/showcase_cascade with country 'cn' and province 'ca'; capture the refusal; then POST province 'zj' and capture the success", + "as the non-admin user: enumerate tier options in the form (Restricted absent), then POST tier='restricted' directly and capture the refusal", + "on a New Invoice, verify the contact picker is empty-scoped before an account is chosen; pick Northwind and capture the picker's data request (scoped to Northwind's contacts); switch to Contoso and verify the candidate set changes" + ], + "acceptance": [ + { + "clause": "dependsOn gates the dependent field until its driver has a value, and changing the driver re-filters the offered set live, clearing a now-invalid selection", + "oracle": "dom", + "verify": "after screenshots, enumerate the offered options at each country state (pinned by objectui e2e/live/cascading-options.spec.ts: 'province options re-filter live as country changes, and the stale value clears')", + "evidence": "option enumerations + screenshots" + }, + { + "clause": "the server rejects an out-of-set submitted option with VALIDATION_FAILED and fields[] carrying {field: 'province', code: 'invalid_option'} — and accepts the in-set one", + "oracle": "api", + "verify": "the two direct POSTs from the steps; refusal envelope must name the field and code (pinned by the same spec's API half; server site: objectql evaluateOptionVisibility, objectui#2284)", + "evidence": "both responses" + }, + { + "clause": "BOTH sides of the role gate: admin sees and can persist tier='restricted'; the non-admin neither sees it NOR can submit it — the direct non-admin POST is refused server-side", + "oracle": "api", + "verify": "admin create re-reads with tier='restricted'; non-admin POST returns the invalid_option refusal (current_user bound from the request on authenticated writes)", + "evidence": "admin re-read + non-admin refusal + both option enumerations" + }, + { + "clause": "the dependent LOOKUP twin works: the invoice contact picker issues account-scoped candidate requests, and switching accounts changes the candidate set (26 for Northwind vs 2 for Contoso)", + "oracle": "network", + "verify": "captured picker requests carry the account scope; candidate counts match the seeded spread", + "evidence": "picker request traces + counts" + }, + { + "clause": "a legal cascade selection persists: create with country=cn, province=zj re-reads verbatim over the API after reload", + "oracle": "api", + "verify": "GET the created row; both values present", + "evidence": "the re-read" + } + ], + "negative": [ + "an out-of-set province accepted with 200 is a FAIL — client hiding is UX, the objectql rule-validator is the boundary", + "a non-admin's direct tier='restricted' POST accepted is a FAIL even though their picker hid the option (UI absence alone is a client courtesy — RUNNER rule 4)" + ], + "traps": [ + "hydration-race", + "automation-input", + "wrong-persona" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/cascading-options.spec.ts" + }, + "source": [ + "examples/app-showcase/src/data/objects/cascading-select.object.ts (the B3 / #1583 fixture, with both-sides contract in its header comment)", + "examples/app-showcase/src/data/objects/invoice.object.ts (contact dependsOn account)", + "examples/app-showcase/src/data/seed/index.ts (contact spread per account)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — deep-test dynamic options: cascade, role gate, dependent lookup, server-side rejection", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.master-detail-atomic-save", + "title": "Master + line items save as one atomic batch, with server-side roll-up", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_invoice + showcase_invoice_line with inlineEdit: 'grid' on the master_detail field (examples/app-showcase/src/data/objects/invoice.object.ts) — every New/Edit Invoice form renders the editable Line Items grid", + "seeded products for the catalog lookup (SKUs incl. SERVICE-HR, WIDGET-A; picking one auto-fills description + unit_price)" + ] + }, + "steps": [ + "open New Invoice (/_console/apps/com.example.showcase/showcase_invoice → New); fill name os-qa-<runid>, account=Northwind, status=draft", + "add two lines in the inline grid: pick a product on each (observe description/unit_price auto-fill), set quantities; watch amount recompute read-only as qty × unit_price", + "Save ONCE; capture the network trace of the save", + "re-read master + lines via GET /api/v1/data/showcase_invoice?$filter=[[\"name\",\"=\",\"os-qa-<runid>\"]] and the lines by the returned invoice id; check invoice.total against the sum of line amounts", + "add a third line via the edit form and save; re-read invoice.total; delete that line; re-read total again", + "repeat the create with one child made deliberately invalid (product empty on a line); attempt save; capture the failure", + "verify no partial writes: filtered GET for the failed invoice name returns 0 rows and no orphan lines exist" + ], + "acceptance": [ + { + "clause": "the happy-path save lands master + children in ONE atomic batch request (not N sequential writes), with the child ops referencing the parent", + "oracle": "network", + "verify": "the save issues a single batch call whose payload contains the parent and both line ops (pinned by objectui e2e/live/master-detail.spec.ts: 'Create submits the populated parent in one atomic batch' / 'includes the child op referencing the parent')", + "evidence": "the batch request payload" + }, + { + "clause": "all rows are readable afterwards and correct: master + 2 lines re-read via API with the entered values; product pick auto-filled description/unit_price persisted", + "oracle": "api", + "verify": "field-by-field diff of the re-read against what was entered/auto-filled", + "evidence": "the reads" + }, + { + "clause": "invoice.total is a SERVER-side roll-up: computed on the atomic create, recomputed when a line is added and again when it is deleted", + "oracle": "api", + "verify": "total == sum(line.amount) after each mutation (pinned by objectui e2e/live/summary-rollup.spec.ts: computed on atomic create; recomputes on child add/delete)", + "evidence": "the three total reads" + }, + { + "clause": "a failing child aborts the WHOLE save — master not created, sibling lines not written, and the form surfaces a named error", + "oracle": "api", + "verify": "after the failed save, the filtered GET returns 0 invoices and no line rows reference a phantom parent", + "evidence": "the empty reads + the error screenshot" + }, + { + "clause": "the relationship-derived subform/grid renders on the standard New form with NO hand-built page (derived from the master_detail declaration)", + "oracle": "dom", + "verify": "after screenshot, the Line Items grid is present on the stock New Invoice form (pinned by objectui e2e/live/form-view-subforms.spec.ts)", + "evidence": "form screenshot" + }, + { + "clause": "the amount expression recomputes live client-side (read-only cell) and the computed value persists as the stored column the roll-up reads", + "oracle": "api", + "verify": "change qty, watch amount update without save; after save the API row carries qty × unit_price", + "evidence": "screenshot + line read" + } + ], + "negative": [ + "the invalid-child case must not partially commit: ANY surviving master or sibling row after the failed save is a FAIL — check by API read, not by the grid", + "a save that issues one write per row (N requests) is a FAIL of the atomicity clause even when all rows land" + ], + "traps": [ + "automation-input", + "hydration-race" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/master-detail.spec.ts, e2e/live/summary-rollup.spec.ts, e2e/live/form-view-subforms.spec.ts" + }, + "source": [ + "#3358 §4", + "examples/app-showcase/src/data/objects/invoice.object.ts (inlineEdit grid, amount expression, total summary)", + "examples/app-showcase/src/data/seed/index.ts (product catalog rows)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358", + "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" + } + ] + }, + { + "id": "records-forms.related-list-server-pagination", + "title": "Related lists derive from the relationship and page on the server — never fetch every child row — and are read-gated on both ends", + "since": "v16", + "status": "active", + "revision": 3, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "showcase_manager — reads BOTH showcase_account AND showcase_contact (readScope org) — the CHILD-read-entitled persona", + "showcase_contributor — reads showcase_account but NOT showcase_contact — the CHILD-read-DENIED persona" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "Northwind account with 26 seeded contacts (2 named + 24 'Prospect NN' rows authored precisely so the related list exceeds a page — examples/app-showcase/src/data/seed/index.ts, objectui#2711)", + "showcase_invoice.account declares relatedList: 'primary' with relatedListTitle 'Invoices' and relatedListColumns [name, status, total, issued_on] (examples/app-showcase/src/data/objects/invoice.object.ts)", + "the permission-zoo sets examples/app-showcase/src/security/permission-sets.ts: showcase_manager grants showcase_contact read (line 124), showcase_contributor omits it (lines 36-45) — the both-sides read-gate probe binds a user to each" + ], + "knownGaps": [ + "the child-read-gate clause needs two signed-up users bound to showcase_manager and showcase_contributor — sys_user rows come from sign-up, not seeds — so on a single-user stock boot record that clause blocked(fixture); the paging clauses run on the seeded admin alone" + ] + }, + "steps": [ + "open the Northwind account detail page (/_console/apps/com.example.showcase/showcase_account → Northwind)", + "screenshot; enumerate the related-list tabs (expect relationship-derived lists incl. Contacts and the declared Invoices tab)", + "open the Contacts related list; capture the page-1 data request", + "click next-page; capture the page-2 request; note the pager text", + "diff page-1 and page-2 row ids for overlap", + "open the Invoices related tab; verify its title and columns match the declaration on the lookup field (name, status, total, issued_on)", + "capture every data request issued by the detail page and check each child query for a $top bound", + "read-gate both-sides: sign in as showcase_contributor (reads showcase_account, NOT showcase_contact); open the Northwind account detail; screenshot and confirm the Contacts related section/tab is ABSENT; then forge GET /api/v1/data/showcase_contact?$filter=[[\"account\",\"=\",\"<northwind id>\"]] as that persona and capture the 403", + "repeat as showcase_manager (has showcase_contact read): confirm the Contacts section renders AND the same child query answers 200 with rows" + ], + "acceptance": [ + { + "clause": "paging issues server-side $top/$skip requests scoped by the parent filter; the full child set is never fetched", + "oracle": "network", + "verify": "page 1 = $top=N + parent filter; page 2 = $top=N&$skip=N; NO child-list request without $top anywhere on the page", + "evidence": "the captured request URLs" + }, + { + "clause": "the pager reflects the server total (26 Northwind contacts), and pages partition the set — no row repeated or skipped across pages", + "oracle": "network", + "verify": "pager reads 'page 2 of M' consistent with total 26; id sets of page 1 and 2 are disjoint", + "evidence": "pager screenshot + the id diff" + }, + { + "clause": "related lists are DERIVED from the relationship declaration with no hand-built page: the Account detail auto-renders them (Projects is the pinned example)", + "oracle": "dom", + "verify": "after screenshot, the related tabs exist on the stock detail page (pinned by objectui e2e/live/detail-related-list.spec.ts)", + "evidence": "detail screenshot" + }, + { + "clause": "the declared relatedList config is honored: the Invoices tab carries the authored title and exactly the authored columns", + "oracle": "dom", + "verify": "after screenshot, read the tab label and column headers; compare to relatedListTitle/relatedListColumns on the lookup field", + "evidence": "tab screenshot + declaration excerpt" + }, + { + "clause": "row navigation works from the related list: clicking a child row opens its record, and the child re-reads via its own API id", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_contact/<clicked id> matches the row navigated to", + "evidence": "navigation screenshot + the read" + }, + { + "clause": "related lists are READ-gated on BOTH ends: a persona WITHOUT read on the child object (showcase_contributor lacks showcase_contact read) sees NO Contacts section on the account detail (UI courtesy — deriveRelatedLists drops children the user cannot read, objectui#2359) AND a direct child query is refused server-side (403); a persona WITH child read (showcase_manager) sees the section AND the query 200s", + "oracle": "api", + "verify": "as showcase_contributor: screenshot confirms the Contacts tab is absent, and the forged GET /api/v1/data/showcase_contact?$filter=[[\"account\",\"=\",\"<northwind id>\"]] returns 403; as showcase_manager: the tab renders and the identical query returns 200 with rows — the server is the authority (ADR-0057 D10, RUNNER rule 4), the UI drop is courtesy", + "evidence": "both personas' detail screenshots + the 403 and the 200 child queries" + } + ], + "negative": [ + "any child-list request WITHOUT a $top bound is a FAIL even when the rendered page looks right — the unpaged fetch is the defect", + "a pager total that disagrees with a direct filtered API count (GET /api/v1/data/showcase_contact?$filter=[[\"account\",\"=\",\"<northwind id>\"]]) is a FAIL", + "a Contacts section rendering for showcase_contributor (an empty grid + a New button that 403s on save) is the objectui#2359 regression shape — FAIL; equally, the child query returning rows to a persona the object read gate denies is a server-side FAIL (UI absence alone never proves the server refuses)" + ], + "traps": [ + "hydration-race" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/detail-related-list.spec.ts" + }, + "source": [ + "#3358 §4 (evidence run captured exactly this trace)", + "examples/app-showcase/src/data/seed/index.ts (the 26-contact Northwind fixture, authored for objectui#2711)", + "examples/app-showcase/src/data/objects/invoice.object.ts (relatedList declaration)", + "objectui: packages/app-shell/src/views/RecordDetailView.tsx (deriveRelatedLists canRead filter — object-level READ gate, objectui#2359)", + "examples/app-showcase/src/security/permission-sets.ts (showcase_manager child read vs showcase_contributor's omission); PENDING-GAPS §E3 / objectui#2565" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from the #3358 evidence run", + "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-08", + "change": "added the §E3 read-gating both-sides clause (child-object read gate: related section absent in the UI AND child query 403s server-side) with the permission-zoo personas (manager reads child, contributor does not); objectui#2359/#2565", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.action-param-widgets", + "title": "Action params render their real widgets, and the param contract is enforced at dispatch", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_action_param_gallery on showcase_field_zoo (examples/app-showcase/src/ui/actions/index.ts): p_text (text, required), p_richtext, p_priority (select), p_date, p_account (lookup→showcase_account), p_assignee (lookup→sys_user), p_color, p_reference (autonumber), p_cover (image, accept image/*, maxSize 5MB), p_attachments (file, multiple, accept pdf+image, maxSize 10MB)" + ] + }, + "variants": [ + "p_text (text)", + "p_richtext (richtext)", + "p_priority (select)", + "p_date (date)", + "p_account (lookup → showcase_account)", + "p_assignee (lookup → sys_user)", + "p_color (color)", + "p_reference (autonumber)", + "p_cover (image)", + "p_attachments (file, multiple)" + ], + "steps": [ + "open Field Zoo (/_console/apps/com.example.showcase/showcase_field_zoo); run the gallery action from the Specimen — Full row menu", + "wait for the dialog; screenshot FIRST", + "enumerate each param's rendered control from the dialog DOM and build the variant→control table", + "read accept/multiple off the two upload inputs as real DOM attributes", + "fill a conformant bag (p_text set, valid p_priority) and confirm; capture the dispatch POST /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery", + "re-open and confirm with p_text EMPTY; capture the outcome", + "direct API negative: POST the dispatch route with a malformed bag (e.g. p_priority out-of-set) and capture the refusal" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: each declared param renders its real widget (date→date input, color→color input, richtext→editor, select→picker, lookup→record picker, file/image→input[type=file], autonumber→read-only server-assigned) — recorded per variant in the table", + "oracle": "dom", + "verify": "after the screenshot confirms render, match each control against the declared param type; every variant row carries its own observed control", + "evidence": "screenshot + the 10-row param→control table" + }, + { + "clause": "accept and multiple are REAL DOM attributes on the upload inputs, matching the declaration (p_cover: image/*; p_attachments: multiple + application/pdf,image/*)", + "oracle": "dom", + "verify": "read the attributes off both inputs post-render", + "evidence": "DOM excerpt" + }, + { + "clause": "the declared param contract is enforced at dispatch BEFORE the body runs (strict since 17.0, #3438): a malformed bag is rejected 400-class; the conformant bag passes and the body echoes the received keys", + "oracle": "api", + "verify": "the two direct POSTs against /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery (pinned by packages/qa/dogfood/test/action-params-contract.dogfood.test.ts, ADR-0104 D2)", + "evidence": "both responses" + }, + { + "clause": "required p_text is enforced on both ends: the dialog blocks confirm (or errors) with it empty, and a direct dispatch without it is refused server-side", + "oracle": "api", + "verify": "UI attempt + direct POST both refuse with a named error; no action execution recorded", + "evidence": "UI screenshot + API refusal" + } + ], + "negative": [ + "maxSize is enforced in JS, not as a DOM attribute — do NOT claim it from attribute absence; it needs an oversized-upload attempt (covered by records-forms.upload-guard-blocks-confirm)", + "a malformed param bag that reaches the action body (echo shows the bad key accepted) is a FAIL — the dispatch gate, not the widget, is the boundary" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "automated": { + "kind": "api", + "ref": "packages/qa/dogfood/test/action-params-contract.dogfood.test.ts; objectui: e2e/live/action-modal.spec.ts" + }, + "source": [ + "#3358 §4 (evidence table)", + "#3393", + "examples/app-showcase/src/ui/actions/index.ts (the gallery action's declared params)", + "ADR-0059 (param-dialog widgets)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358, keeping its maxSize caveat as a negative-side note", + "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" + } + ] + }, + { + "id": "records-forms.upload-guard-blocks-confirm", + "title": "Confirm stays disabled while a file param is still uploading; maxSize is enforced by a real oversized attempt", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_action_param_gallery's p_attachments (maxSize 10MB) and p_cover (maxSize 5MB) — the ADR-0059 upload-guard fixtures (examples/app-showcase/src/ui/actions/index.ts)" + ] + }, + "steps": [ + "open the gallery action dialog from the Field Zoo row menu (screenshot first)", + "prepare a real file large enough to catch mid-flight (~8MB, under the 10MB p_attachments limit); start the upload into p_attachments", + "while the upload is in flight, attempt to click Confirm; capture the network trace across the attempt", + "wait for upload completion; confirm; capture the dispatch request", + "re-open the dialog; attempt an OVERSIZED file (>10MB) into p_attachments and (>5MB) into p_cover; capture the client's response", + "verify no upload request was issued for the oversized attempts" + ], + "acceptance": [ + { + "clause": "the Confirm control is actually disabled mid-upload and enables on completion — proven by catching a real upload in flight, not by reading the helper label", + "oracle": "dom", + "verify": "during the in-flight window the button carries disabled state AND clicking issues no dispatch request; after completion the confirm succeeds", + "evidence": "mid-flight screenshot + network trace showing no premature submit" + }, + { + "clause": "the completed dispatch carries the uploaded file reference and the action executes", + "oracle": "network", + "verify": "the POST /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery after completion returns success with the file param populated", + "evidence": "the dispatch trace" + }, + { + "clause": "maxSize is enforced by attempt: an oversized file is rejected with a named client error BEFORE any upload request is issued (both the 10MB and the 5MB limits)", + "oracle": "network", + "verify": "the oversized attempts produce a visible error and zero upload requests in the trace", + "evidence": "error screenshots + the empty trace window" + }, + { + "clause": "a rejected oversized file leaves the dialog usable: a subsequent valid file uploads and confirms normally", + "oracle": "dom", + "verify": "after the rejection, repeat a valid upload and confirm", + "evidence": "final success screenshot" + } + ], + "negative": [ + "a confirm click mid-upload that ISSUES the dispatch is a FAIL even if the server would cope", + "an oversized file that starts uploading (any upload request observed) is a FAIL — the guard must reject before the wire" + ], + "traps": [ + "automation-input", + "hydration-race" + ], + "source": [ + "#3358 §4 ('leaving it unticked on the strength of a label')", + "ADR-0059", + "examples/app-showcase/src/ui/actions/index.ts (maxSize declarations)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — codifies the #3358 refusal to tick from a label into the oracle itself", + "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" + } + ] + }, + { + "id": "records-forms.import-wizard-encoding-and-hints", + "title": "CSV import: GBK decode, required-field hint, legacy-fallback notice", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "an importable showcase object (e.g. showcase_contact) and a GBK-encoded CSV fixture; the pinned unit suites carry their own byte fixtures" + ] + }, + "steps": [ + "run the pinned unit suites (they feed real GBK bytes and assert the exact decode, the disabled-Next hint, and the visible degrade notice): pnpm --filter @object-ui/plugin-grid exec vitest run src/importParsers.test.ts src/__tests__/importMissingRequiredHint.test.tsx src/__tests__/importLegacyReferenceGuard.test.tsx", + "capture the suite output as the primary evidence (the native file-picker hand-off is deliberately not browser-automated)", + "optionally spot-check visually: open the import wizard on showcase_contact, load a GBK CSV past the native picker, and screenshot the decoded preview", + "in the spot-check, map columns while leaving a required field (name) unmapped; observe the Next control and its hint", + "verify the wizard's outcome server-side after a completed import: filtered GET /api/v1/data/showcase_contact for an imported marker row" + ], + "acceptance": [ + { + "clause": "GBK bytes decode to the exact expected strings (no mojibake) — pinned", + "oracle": "test", + "verify": "pnpm --filter @object-ui/plugin-grid exec vitest run src/importParsers.test.ts", + "evidence": "test run output" + }, + { + "clause": "an unmapped required field disables Next AND shows the named required-field hint — pinned", + "oracle": "test", + "verify": "pnpm --filter @object-ui/plugin-grid exec vitest run src/__tests__/importMissingRequiredHint.test.tsx", + "evidence": "test run output" + }, + { + "clause": "the legacy-reference fallback path shows a visible degrade notice instead of silently degrading — pinned", + "oracle": "test", + "verify": "pnpm --filter @object-ui/plugin-grid exec vitest run src/__tests__/importLegacyReferenceGuard.test.tsx", + "evidence": "test run output" + }, + { + "clause": "a completed import lands rows the API can read back (the wizard's end state is server rows, not a success toast)", + "oracle": "api", + "verify": "filtered GET for an imported marker row returns it with decoded values intact", + "evidence": "the read" + } + ], + "negative": [ + "a GBK file whose preview renders mojibake while the tests pass means the WIRED wizard regressed against the pinned parser — a FAIL, file against the wizard wiring", + "an import that reports success while the filtered API read finds no rows is a FAIL (silent drop)" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-grid/src/importParsers.test.ts (+ importMissingRequiredHint, importLegacyReferenceGuard)" + }, + "traps": [ + "automation-input", + "stale-console-bundle" + ], + "source": [ + "#3358 §4 (ticked on test evidence — the native file picker hand-off is deliberately not automated)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial import from #3358's test-evidence resolution", + "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" + } + ] + }, + { + "id": "records-forms.action-location-matrix", + "title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section, global nav — and each dispatches for real", + "since": "v15", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header+record_section, script, visible '!record.done'), showcase_log_time (record_header+record_related+record_section, form), showcase_open_docs (record_more, url), showcase_recalc_selection (record_more, api — deliberately kept OFF the toolbar, objectui#3142), showcase_new_task (global_nav, modal)", + "seeded tasks in both done and not-done states for the CEL visibility both-sides check" + ] + }, + "variants": [ + "list_toolbar — showcase_bulk_reassign on the task list header", + "list_item — showcase_quick_view / showcase_mark_done in the row menu", + "record_header — showcase_mark_done / showcase_log_time in the detail title bar", + "record_more — showcase_open_docs / showcase_recalc_selection under the ⋯ overflow", + "record_related — showcase_log_time on the related-list section", + "record_section — showcase_mark_done / showcase_log_time in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)", + "global_nav — showcase_new_task in the command palette / global nav", + "empty-locations semantics probe — a locations-less action lands on EVERY location including the toolbar (objectui action-bar.tsx documented behavior; the reason recalc_selection must declare record_more)" + ], + "steps": [ + "boot the showcase isolated; sign in as admin; open the showcase_task list view", + "for each location variant, navigate to its surface (list header / row menu / a not-done task's detail title bar / its ⋯ menu / a related-list section / the Task Detail quick-actions bar / the global command palette), screenshot AFTER render settles, then read the rendered action buttons from the DOM", + "record for every fixture action WHERE it rendered — building the full placement matrix (rendered locations vs declared locations)", + "dispatch one action per location with a ref-targeted click: bulk_reassign (screen-flow wizard opens), quick_view (modal opens), mark_done (script executes), log_time (form dialog opens on showcase_task.edit), open_docs (url navigation), recalc_selection from the ⋯ menu (api POST), new_task from the palette; capture each network trace", + "verify the state-changing dispatches server-side: mark_done flips the task's done flag (API re-read), recalc_selection's per-record branch recomputes the estimate", + "CEL visibility both sides: locate a done task and a not-done task; read the row menu and record header of each for showcase_mark_done", + "empty-locations probe: in a scratch/writable package author a copy of an api action with NO locations key; reload and record every surface it appears on (including the toolbar), then delete the probe" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: every location renders at least one action declared for it, in the correct UI slot — the full placement matrix (rendered vs declared) has zero missing placements", + "oracle": "dom", + "verify": "after each surface's screenshot confirms render, the DOM read lists the expected action names in that slot; matrix compiled per variant", + "evidence": "per-location screenshots + the placement matrix" + }, + { + "clause": "placement is EXCLUSIVE, not additive: the matrix has zero extra placements — record_more-only actions (open_docs, recalc_selection) never render in record_header or list_toolbar; global_nav-only new_task never renders on rows; the engine location-filters even explicitly-named actions (the record:quick_actions bar note in the fixture source)", + "oracle": "dom", + "verify": "the placement matrix's extra-placement cells are all empty, checked against every captured surface", + "evidence": "the same matrix, extra-placement columns" + }, + { + "clause": "each location's sampled action DISPATCHES for real — flow wizard opens and resumes, modal opens, script executes, form opens the declared edit form view, url navigates, api POSTs — and state-changing ones round-trip server-side (mark_done flips done; recalc updates the estimate)", + "oracle": "network", + "verify": "one captured dispatch per location + API re-reads for the two state changes", + "evidence": "the seven traces + the two re-reads" + }, + { + "clause": "row-level CEL visibility gates per record, both sides: showcase_mark_done ('visible: !record.done') renders on the not-done task's row/header and is ABSENT on the done task's — and the evaluation is fail-closed (a throwing expression hides, never shows)", + "oracle": "dom", + "verify": "side-by-side DOM reads of the two rows and the two record headers", + "evidence": "the four reads + screenshots" + }, + { + "clause": "empty/missing locations means EVERY location — the probe action appears on all surfaced slots including the list toolbar (the objectui#3142 semantics that forces recalc_selection to declare record_more, because a toolbar dispatch has no selection and the endpoint rejects it)", + "oracle": "dom", + "verify": "the locations-less probe's placement list covers all applicable surfaces; recalc_selection itself stays OFF the toolbar", + "evidence": "probe placement list + toolbar DOM read" + } + ], + "negative": [ + "any action rendering at a location it did not declare (and did not inherit via the empty-locations rule) is a FAIL — placement is a contract, not a hint", + "showcase_recalc_selection appearing on the list toolbar is the objectui#3142 regression shape — FAIL even though clicking it would merely error", + "a dispatch that opens the wrong target (e.g. log_time opening a list view instead of the showcase_task.edit form — the #2554 build-gate class) is a FAIL of the dispatch clause, not a cosmetic note" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle" + ], + "automated": { + "kind": "e2e", + "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:397 (ACTION_LOCATIONS — the canonical 7-value enum, single source of truth)", + "examples/app-showcase/src/ui/actions/index.ts (per-location fixture fleet + the record:quick_actions filter note + the objectui#3142 empty-locations commentary)", + "objectui: packages/.../action-bar.tsx (missing/empty locations → every location)", + "cross-ref: bulk dispatch-count semantics live in records-forms.list-view-capabilities (bulk-actions variant); param dialogs in records-forms.action-param-widgets" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — closes the button-placement gap: list toolbar / list row / bulk bar / detail-page buttons were covered piecemeal but never as the ACTION_LOCATIONS matrix", + "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-08", + "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly", + "ref": "claude/platform-test-checklist-ocwugl" + } + ], + "enumSource": { + "file": "packages/spec/src/ui/action.zod.ts", + "export": "ACTION_LOCATIONS", + "expect": 7 + } + }, + { + "id": "records-forms.validation-rule-type-matrix", + "title": "All six validation-rule types enforce on the write path with their exact per-type error codes", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the seeded per-type rules: showcase_account account_lifecycle (state_machine), tax_id_format + billing_email_format (format), support_config_shape (json_schema), churn_reason_consistency (conditional); showcase_project end_after_start (cross_field), spent_within_budget (script), project_status_flow (state_machine)" + ] + }, + "variants": [ + "state_machine — create with non-initial status → invalid_initial_state; illegal transition → invalid_transition", + "format — bad tax_id / billing_email → invalid_format", + "json_schema — off-schema support_config → json_schema_violation; non-JSON string → invalid_json", + "cross_field — project end_date < start_date → rule_violation naming end_date", + "script — spent > budget → rule_violation", + "conditional — status 'churned' without churn_reason → the wrapped rule fires; with churn_reason present it does not" + ], + "steps": [ + "boot showcase isolated; sign in as admin", + "for each variant: POST/PATCH the violating payload over /api/v1/data/<object>; capture status + error body", + "for each variant: send the happy-path twin (same shape, satisfying values) and capture success", + "after every rejection, re-read the row set to confirm nothing persisted", + "for state_machine additionally: create with a legal initial state, walk one legal transition, then attempt the illegal one" + ], + "acceptance": [ + { + "clause": "each violating write answers 400 VALIDATION_FAILED with the per-type field code exactly as ledgered: invalid_initial_state / invalid_transition / invalid_format / json_schema_violation / invalid_json / rule_violation — six variants, six distinct proofs, none inferred from a sibling", + "oracle": "api", + "verify": "per-variant response status + fields[].code against the rule-validator dispatch (packages/objectql/src/validation/rule-validator.ts evaluateRule)", + "evidence": "the six response bodies keyed by variant" + }, + { + "clause": "the error targets the declared field (cross_field targets fields[0] per the spec's own comment; format targets the formatted field) — actionable, not a bare object-level failure", + "oracle": "api", + "verify": "fields[].field matches the rule's declared target per variant", + "evidence": "the field targeting in each body" + }, + { + "clause": "no rejected write persists — row counts and byte-identical rows across each rejection", + "oracle": "api", + "verify": "post-rejection re-reads", + "evidence": "the re-reads" + }, + { + "clause": "every happy-path twin lands 2xx — the rules gate violations, they do not block legitimate writes", + "oracle": "api", + "verify": "the six success responses + persisted rows", + "evidence": "the twins" + } + ], + "negative": [ + "an unevaluable CEL expression must fail CLOSED (rule_violation), never fail-open silently accepting the write — the rule-validator's documented posture" + ], + "traps": [ + "wrong-persona" + ], + "source": [ + "packages/spec/src/data/validation.zod.ts (ValidationRuleSchema, 6 discriminated variants)", + "packages/objectql/src/validation/rule-validator.ts (evaluateRule switch + per-type codes)", + "examples/app-showcase/src/data/objects/{account,project,task}.object.ts (the seeded rules)", + "#1475 (declared ≠ enforced history: 9 declared → 6 declared+enforced)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — gap found by the capability sweep: 6 rule types all seeded, none individually asserted anywhere", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.gantt-interactions", + "title": "Gantt is interactive, not a picture: drag persists, locked tasks survive auto-schedule, host veto restores", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the task gantt view (task.view.ts 'Schedule (Gantt)': startDateField/endDateField/titleField/progressField)" + ], + "knownGaps": [ + "the stock view declares NO dependenciesField / lockField / parentField, so auto-schedule + locked-task + subtree-drag surfaces are unreachable on stock fixtures — needs the view extended (objectui carries unit pins for those behaviors: GanttView.autoscheduledlg / summaryedit / deptypes tests)" + ] + }, + "variants": [ + "bar drag → server PATCH (runnable on stock fixture)", + "auto-schedule confirms first: 'Shift N task(s)… (M locked skipped)' and writes NOTHING before confirm (fixture-gated)", + "locked task refuses drag and survives auto-schedule byte-identical (fixture-gated)", + "host onBeforeTaskUpdate veto restores the bar with no write (fixture-gated)" + ], + "steps": [ + "open the task Schedule (Gantt) view; wait for bars to render (screenshot first)", + "drag one task bar to new dates; capture the PATCH and re-read the record over the API", + "reload and confirm the bar re-renders from the persisted dates", + "on an extended fixture (per knownGaps): configure dependencies + a locked row; run toolbar auto-schedule; capture the confirm dialog, cancel once (verify zero writes), run again and apply", + "trigger a veto path (write rejected server-side) and confirm the bar snaps back" + ], + "acceptance": [ + { + "clause": "a bar drag issues the record PATCH and the API re-read shows the new start/end — pixels are not the oracle, the row is", + "oracle": "api", + "verify": "captured PATCH + re-read; reload re-renders from server values", + "evidence": "trace + re-read + post-reload screenshot" + }, + { + "clause": "auto-schedule is confirm-first: cancel writes nothing (row set byte-identical), apply shifts exactly the unlocked affected set and reports skipped locked count", + "oracle": "api", + "verify": "row-set diff after cancel (empty) and after apply (only unlocked tasks moved); dialog text carries N and M", + "evidence": "diffs + dialog screenshot" + }, + { + "clause": "a locked task's dates survive both direct drag attempts and auto-schedule unchanged", + "oracle": "api", + "verify": "before/after reads on the locked row", + "evidence": "the reads" + }, + { + "clause": "a vetoed update leaves no write and restores the visual state", + "oracle": "api", + "verify": "no PATCH lands (or the failed one has no effect) and the re-read is unchanged", + "evidence": "trace + re-read" + } + ], + "negative": [ + "a drag that repaints the bar but lands no PATCH (or a PATCH that 4xxs while the bar keeps the new position) is a FAIL — the #3358 §8 rows exist precisely because gantt can lie visually" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-gantt/src (GanttView.autoscheduledlg.test.tsx, GanttView.summaryedit.test.tsx, GanttView.deptypes.test.tsx, scheduling.selfextent.test.ts)" + }, + "source": [ + "#3358 §8 (the three never-imported gantt rows)", + "objectui: packages/plugin-gantt/src/GanttView.tsx + scheduling.ts (RescheduleResult.skippedLocked)", + "packages/spec/src/ui/view.zod.ts (GanttConfigSchema)", + "examples/app-showcase/src/ui/views/task.view.ts (stock fixture limits)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — #3358 §8 interaction rows were never imported; render-only coverage existed in view-type-gallery", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.kanban-drag-persistence", + "title": "Kanban card drag across columns persists the group-field change server-side", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the task kanban view (grouped by status) with cards in at least two columns" + ] + }, + "steps": [ + "open the task kanban; screenshot after render", + "drag one card from column A to column B; capture the update request", + "re-read the record over the API; reload the view and locate the card", + "attempt a drag that the server rejects (e.g. an illegal state_machine transition per task_status_flow) and observe the recovery" + ], + "acceptance": [ + { + "clause": "the drop issues the record update carrying the new group-field value and the API re-read confirms it", + "oracle": "api", + "verify": "captured PATCH + re-read shows status = column B's value", + "evidence": "trace + re-read" + }, + { + "clause": "the move survives a reload — the card renders in column B from server state", + "oracle": "screenshot", + "verify": "post-reload screenshot", + "evidence": "screenshot" + }, + { + "clause": "a server-rejected move (illegal FSM transition → 400 invalid_transition) returns the card to its source column with a visible error — not a silently stuck optimistic state", + "oracle": "network", + "verify": "the 400 + the card's post-rejection column + the surfaced error", + "evidence": "trace + screenshot" + } + ], + "negative": [ + "an optimistic move that sticks visually after a failed write is the FAIL this item exists for — cross-checks records-forms.validation-rule-type-matrix's state_machine variant from the UI side" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "source": [ + "objectui: packages/plugin-kanban/src/KanbanImpl.tsx (onDragEnd)", + "examples/app-showcase/src/data/objects/task.object.ts (task_status_flow state_machine)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — kanban was render-only in view-type-gallery; the drag interaction chain had no coverage", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.form-dirty-guard", + "title": "Dirty forms guard navigation: discard prompts, save proceeds, nothing is lost silently", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "admin" + ], + "steps": [ + "open a record edit form (modal or drawer); change one field WITHOUT saving", + "attempt to close the dialog / navigate away; capture the guard prompt", + "choose stay/cancel — confirm the edit is still in the form", + "choose discard — confirm the record is unchanged server-side and the form state is dropped", + "repeat the edit and save normally — confirm persistence", + "repeat with NO edits: closing must NOT prompt (clean forms exit freely)" + ], + "acceptance": [ + { + "clause": "a dirty form intercepts close/navigation with the discard guard; a clean form closes without friction — both sides", + "oracle": "screenshot", + "verify": "prompt appears exactly when dirty", + "evidence": "both screenshots" + }, + { + "clause": "discard leaves the server row byte-identical; save persists — the guard's two exits both behave", + "oracle": "api", + "verify": "re-reads after each exit", + "evidence": "the reads" + }, + { + "clause": "the guard's behavior is pinned by the existing unit suite", + "oracle": "test", + "verify": "objectui: pnpm --filter @object-ui/plugin-form exec vitest run src/discardGuard.test.tsx", + "evidence": "test output" + } + ], + "negative": [ + "losing a dirty edit on close with NO prompt is the FAIL; equally, prompting on a pristine form is a paper-cut FAIL of the clean side" + ], + "traps": [ + "automation-input" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-form/src/discardGuard.test.tsx" + }, + "source": [ + "objectui: packages/plugin-form/src/{ModalForm,DrawerForm}.tsx (beforeunload/guard wiring)", + "dogfood-verification skill §4 (the beforeunload escape hatch exists precisely because this guard is real)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-07", + "change": "initial — the dirty-state guard existed (with a unit pin) but no checklist item asserted it", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.named-import-mapping", + "title": "A named import mapping maps foreign CSV headers and re-imports idempotently", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "admin" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped mapping examples/app-showcase/src/data/mappings/ (showcase_inquiry_feed) targeting showcase_inquiry, with a CSV whose headers are Full Name / Channel (not the field names)" + ] + }, + "steps": [ + "POST /api/v1/data/showcase_inquiry/import with mappingName: 'showcase_inquiry_feed' and a foreign-header CSV", + "read back the created rows over /data", + "POST the SAME file again (idempotence probe)", + "POST with mappingName: 'no_such_mapping'" + ], + "acceptance": [ + { + "clause": "foreign headers land on the mapped fields — 'Full Name' → the name field, 'Channel' → the source field — per the named mapping, not positional guessing", + "oracle": "api", + "verify": "created rows carry the CSV values on the mapped target fields", + "evidence": "the reads" + }, + { + "clause": "re-importing the same file is idempotent (upsert on the mapping's key, e.g. email) — no duplicate rows", + "oracle": "api", + "verify": "row count unchanged after the second import", + "evidence": "before/after counts" + }, + { + "clause": "an unknown mapping name fails loudly with a located error — never a silent positional fallback", + "oracle": "api", + "verify": "the bad-mapping response is a 4xx naming the missing mapping", + "evidence": "the response" + } + ], + "negative": [ + "a duplicate-creating re-import (upsert key ignored) is a FAIL; so is a silent positional import when the named mapping is missing" + ], + "traps": [ + "seed-data-thin" + ], + "source": [ + "examples/app-showcase/src/data/mappings/ (showcase_inquiry_feed)", + "content/docs tour_data (named mapping claim)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — the coverage.json `mapping` waiver was STALE (showcase ships showcase_inquiry_feed); un-waived", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.adhoc-filter-sort-builder", + "title": "User-built FilterBuilder/SortBuilder toolbar merges with the view filter, restores across nav, and reproduces rows from the URL", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a second account signed up on the same browser (for the user-scoping probe — sys_user rows come from sign-up)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_task with its saved views (examples/app-showcase/src/ui/views/task.view.ts) — the in_progress view carries a stored filter (status equals in_progress) so a toolbar condition can be proven to MERGE with, not replace, the view filter", + "10 seeded tasks spanning statuses/priorities/estimate_hours so a 2-condition toolbar filter returns a non-trivial subset (examples/app-showcase/src/data/seed/index.ts)" + ] + }, + "steps": [ + "sign in as admin; open showcase_task and switch to the in_progress saved view (stored filter status=in_progress); screenshot", + "open the FilterBuilder toolbar; add 2 conditions (e.g. priority = high, estimate_hours > 4); open the SortBuilder and add a sort (due_date desc); capture the data request", + "assert the request $filter is the MERGE of the view's stored rule AND the two toolbar conditions (buildEffectiveFilter/mergeFilterNodes), and $orderby carries the toolbar sort; the returned rows satisfy view-filter AND toolbar conditions", + "navigate away via an in-app nav link to another object (the link carries no query string, so the URL state is dropped), then navigate back to showcase_task in_progress; screenshot; confirm the toolbar filter + sort restored", + "read localStorage key list-filters:<userId>:showcase_task:<viewId> and confirm it embeds the signed-in user id", + "sign out; sign in as the SECOND account; open the same object+view; confirm the first account's cached toolbar filter does NOT appear", + "userFilters/URL: where UserFilters are hosted (an interface page or the userFilters surface — NOT a bare object list, ADR-0053), apply a quick-filter; copy the URL; open it in a fresh tab; confirm the uf_<field> params (comma-joined, URI-encoded) reproduce the same rows and uf__tab carries the active preset (ADR-0047)", + "negative sweep: confirm userFilters (quick-filter chips / uf_* params) do NOT leak onto the bare object list view" + ], + "acceptance": [ + { + "clause": "the FilterBuilder toolbar conditions MERGE with the view's stored filter — the data request $filter carries BOTH the view rule AND the user conditions (buildEffectiveFilter over baseFilter + userFilter + normalized per-field conditions), never a replacement that drops the view filter", + "oracle": "network", + "verify": "captured GET /api/v1/data/showcase_task with $filter containing the view's status=in_progress node AND the two toolbar conditions merged (objectui plugin-list/src/ListView.tsx buildEffectiveFilter → mergeFilterNodes); rows returned satisfy every clause", + "evidence": "the request URL + first-page rows" + }, + { + "clause": "the SortBuilder sort reaches the request as $orderby and the rows come back server-ordered (client re-sorting is not the oracle)", + "oracle": "network", + "verify": "the request carries $orderby for the toolbar sort field/direction; response row order matches", + "evidence": "the request URL + row order" + }, + { + "clause": "the toolbar filter + search survive a FULL in-app navigation away and back — restored from localStorage (URL params alone are lost on an in-app nav link that carries no query string; listFilterStorage exists precisely to bridge that)", + "oracle": "dom", + "verify": "after a screenshot confirms the list rendered, the FilterBuilder shows the two conditions and the SortBuilder the sort; the request re-issued on return carries the same merged $filter (app-shell/src/views/listFilterStorage.ts)", + "evidence": "before/after screenshots + the re-issued request" + }, + { + "clause": "the localStorage cache is USER-SCOPED — the key embeds the user id (list-filters:<userId>:<object>:<view>) so a second account on the same browser never reads the first account's cached filters (a filter value can be sensitive)", + "oracle": "dom", + "verify": "read the storage key as account A, then sign in as account B and confirm B's view opens with no A-authored toolbar filter; anon falls to its own 'anon' bucket (buildListFilterKey)", + "evidence": "the two accounts' storage keys + B's clean toolbar" + }, + { + "clause": "uf_* URL params make a filtered list shareable and reproducible: uf_<field> params (comma-joined, each URI-encoded) reproduce the same rows on a fresh load and uf__tab carries the active preset (ADR-0047)", + "oracle": "network", + "verify": "opening the copied URL fresh issues the same filtered data request and returns the same rows (app-shell/src/views/userFilterUrlState.ts parseUserFilterParams/applyUserFilterParams)", + "evidence": "the shared URL + the reproduced rows" + } + ], + "negative": [ + "a toolbar filter that REPLACES the view's stored filter (rows appear that the view filter should exclude) is a FAIL — buildEffectiveFilter merges, it does not overwrite the base", + "userFilters (quick-filter chips / uf_* params) leaking onto a bare OBJECT list view is a FAIL — ADR-0053 suppresses them there by design (filter elements belong to interface pages; the list-view-capabilities item flags the same regression)", + "a cached toolbar filter from account A visible to account B on the same browser is a FAIL — the key embeds the user id precisely to prevent that leak", + "an incomplete FilterBuilder row emitted as [field, op, ''] (which matches only empty and silently excludes everything) instead of being dropped is a FAIL — convertFilterGroupToAST skips valueless rows (#1964)" + ], + "traps": [ + "hydration-race", + "shared-browser-tab", + "stale-console-bundle" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/user-filters.spec.ts, e2e/live/saved-view-filter.spec.ts" + }, + "source": [ + "objectui: packages/plugin-list/src/ListView.tsx (buildEffectiveFilter, convertFilterGroupToAST, mergeFilterNodes; $filter + $orderby assembly)", + "objectui: packages/app-shell/src/views/listFilterStorage.ts (user-scoped localStorage key, debounced write, clear)", + "objectui: packages/app-shell/src/views/userFilterUrlState.ts (uf_* params, ADR-0047)", + "ADR-0053 (userFilters belong to interface pages, suppressed on object list views); examples/app-showcase/src/ui/views/task.view.ts", + "cross-ref: records-forms.list-view-capabilities (saved-view-filter / userFilters negative)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test the ad-hoc FilterBuilder/SortBuilder toolbar: view-filter merge + $orderby, localStorage nav-restore (user-scoped), uf_* URL reproduction, ADR-0053 leak negative", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.calendar-interactions", + "title": "Calendar is interactive: drag reschedules via PATCH, a failing PATCH reverts with an error, mode switches re-render, empty-day click quick-creates", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the showcase_task Calendar view (examples/app-showcase/src/ui/views/task.view.ts: named 'Calendar' view + allowedVisualizations calendar, calendar.startDateField due_date, titleField title, colorField status)", + "10 seeded tasks carrying due_date values so cards land on distinct days (examples/app-showcase/src/data/seed/index.ts)" + ] + }, + "variants": [ + "month-view card drag (move) → PATCH due_date", + "week/day time-grid move → PATCH start (+ end)", + "resize-end → PATCH end only", + "empty-day/slot click → quick-create" + ], + "steps": [ + "open showcase_task; switch to the Calendar view; wait for cards to render (screenshot FIRST)", + "drag one task card to another day; capture the PATCH /api/v1/data/showcase_task/<id> carrying the new due_date; re-read the record over the API", + "reload the view; confirm the card renders on the new day from server state", + "force a FAILING reschedule (drive as a persona lacking edit, or drag a record the server rejects); observe the optimistic move, then the rollback to the original day AND an error toast", + "switch month → week → day modes; confirm the same records re-render placed by their date fields", + "click an empty day cell; a quick-create dialog opens pre-filled with that date; submit; capture the create and re-read the new row's due_date = the clicked day", + "capture one screenshot per interaction variant" + ], + "acceptance": [ + { + "clause": "a card drag issues the record PATCH carrying the new date field(s) and the API re-read confirms the persisted value — pixels are not the oracle, the row is", + "oracle": "api", + "verify": "captured PATCH /api/v1/data/showcase_task/<id> with the new due_date (ObjectCalendar handleEventDropDefault → dataSource.update); GET re-read matches; reload re-renders from server state", + "evidence": "PATCH trace + re-read + post-reload screenshot" + }, + { + "clause": "a FAILING PATCH REVERTS the optimistic move AND surfaces an error toast — never a silent snap-back that hides the failure (a 403 RLS denial is the common case)", + "oracle": "dom", + "verify": "after screenshot, the card returns to its original day and a visible error toast appears (ObjectCalendar rolls back setData(prevData) + toast.error, cloud#864); the geometry is unit-pinned by CalendarView.dnd.test.tsx", + "evidence": "before/after screenshots + the failed PATCH trace" + }, + { + "clause": "month/week/day mode switches re-render the SAME records against their date fields (not a blank grid, not a grid fallback)", + "oracle": "dom", + "verify": "after screenshots at each mode, the seeded tasks appear placed by due_date/start; spot-check 2 against their API-read dates", + "evidence": "per-mode screenshots + 2 API reads" + }, + { + "clause": "empty-day quick-create inserts a REAL row: the dialog pre-fills the clicked date, submit issues dataSource.create, and the API re-read shows the new row with its date field = the clicked day", + "oracle": "api", + "verify": "capture the create request and GET the new row; the start/due date equals the clicked cell's date (ObjectCalendar quick-create → dataSource.create)", + "evidence": "create trace + the row read" + } + ], + "negative": [ + "a drag that repaints the card but lands NO PATCH (or a 4xx PATCH while the card keeps the new day) is a FAIL — the calendar can lie visually, the same class as the gantt §8 rows", + "a failed reschedule that silently snaps back with NO error surfaced is a FAIL — the code explicitly rolls back AND toasts; a silent revert would hide a real RLS denial" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-calendar/src/CalendarView.dnd.test.tsx (move/resize/time-grid drag geometry)" + }, + "source": [ + "objectui: packages/plugin-calendar/src/ObjectCalendar.tsx (handleEventDropDefault optimistic update + rollback + toast; empty-day quick-create → dataSource.create)", + "objectui: packages/plugin-calendar/src/CalendarView.tsx + CalendarView.dnd.test.tsx", + "examples/app-showcase/src/ui/views/task.view.ts (Calendar view: startDateField due_date, colorField status)", + "cross-ref: records-forms.view-type-gallery (calendar render-only), records-forms.kanban-drag-persistence / gantt-interactions (sibling drag-persist items)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — calendar was render-only in view-type-gallery; this deep-tests the drag→PATCH→revert chain, mode switches, and empty-day quick-create", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.concurrent-edit-conflict", + "title": "Concurrent edits collide loudly (OCC 409 + conflict dialog), never silent last-write-wins; inline two-surface edit is ONE atomic OCC-guarded save", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) in TWO independent browser sessions (distinct auth cookies), 'A' and 'B'" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "any writable object whose rows carry an updated_at OCC token (e.g. showcase_account or showcase_task) — the form reads updated_at and sends it as If-Match (objectui plugin-form/src/occSave.tsx; framework packages/metadata-protocol/src/protocol.ts updateData If-Match handling)", + "a record with both a details-body field and a header-highlight field for the two-surface inline-edit fold-in (RecordDetailView highlight fields)" + ] + }, + "steps": [ + "open the SAME record's detail in session A and session B (both hold the same updated_at)", + "in A: edit one field, Save; capture the PATCH carrying If-Match: <updated_at> → 200; the record's updated_at advances", + "in B (still holding the STALE updated_at): edit the same field, Save; capture the response", + "assert B receives 409 CONCURRENT_UPDATE and the conflict dialog appears (Reload latest / Overwrite) — NOT a silent overwrite, NOT a bare error", + "in B choose Reload → the form refetches and shows A's value", + "in B re-edit and choose Overwrite → the retry re-keys ifMatch to the version the 409 reported → 200; re-read shows exactly one winner (B's value)", + "INLINE two-surface (§E1): on the detail, enter the shared inline-edit session; edit one details-BODY field AND one header-HIGHLIGHT field; confirm ONE Save bar; Save; capture the single PATCH and its keys + headers", + "stale-ifMatch inline path: repeat the inline save holding a stale updated_at (a concurrent write landed between) and capture the outcome" + ], + "acceptance": [ + { + "clause": "the losing save is REFUSED with 409 CONCURRENT_UPDATE server-side — never a silent last-write-wins that overwrites A with no signal", + "oracle": "api", + "verify": "B's PATCH returns HTTP 409 with code CONCURRENT_UPDATE (framework rest-server.ts error mapping: error.code CONCURRENT_UPDATE / ConcurrentUpdateError → 409); A's value is intact on an independent re-read", + "evidence": "B's 409 response + the intact re-read" + }, + { + "clause": "the conflict surfaces a conflict DIALOG offering Reload/Overwrite (Keep editing) — a structured choice, not a silent overwrite and not a raw stack trace", + "oracle": "dom", + "verify": "after screenshot, the ConcurrentUpdateDialog (plugin-detail) / occSave conflict dialog (plugin-form) renders with the racer's version and the two actions", + "evidence": "the conflict-dialog screenshot" + }, + { + "clause": "Reload DISCARDS B's pending edit and refetches — B's form then shows A's value", + "oracle": "api", + "verify": "after Reload, the form's field equals A's saved value (a fresh GET), and no B write landed", + "evidence": "the refetch read + no-write trace" + }, + { + "clause": "Overwrite re-keys ifMatch to the version the 409 reported and lands EXACTLY ONE winner on re-read — an explicit last-write chosen by the user, not an accident", + "oracle": "api", + "verify": "the overwrite retry carries the 409-reported currentVersion as ifMatch → 200; the final re-read shows B's value, with no lost-update ambiguity (occSave settle/retry path)", + "evidence": "the overwrite PATCH + final re-read" + }, + { + "clause": "inline two-surface edit is ONE atomic save: editing one details-body field AND one header-highlight field drives ONE Save bar and issues ONE PATCH carrying EXACTLY those two keys plus ifMatch = the read updated_at (the draft holds changed keys only — never computed/readonly/untouched fields)", + "oracle": "network", + "verify": "the single PATCH body has exactly the two edited keys and an If-Match header (objectui InlineEditSaveBar dataSource.update(obj,id,draft,{ifMatch:data.updated_at}); InlineEditContext draft = changed keys; pinned family e2e/live/inline-edit-polish-2572.spec.ts)", + "evidence": "the single PATCH body + headers" + }, + { + "clause": "the inline path is OCC-guarded too: an inline save on a stale ifMatch → 409 conflict (same dialog), not a silent overwrite", + "oracle": "api", + "verify": "the stale inline PATCH returns 409 CONCURRENT_UPDATE and the conflict dialog appears", + "evidence": "the 409 + dialog" + } + ], + "negative": [ + "silent last-write-wins — B's save overwriting A with no 409 and no dialog — is THE FAIL this item exists to catch", + "an inline two-surface edit that issues TWO PATCHes (one per surface) or a single PATCH carrying untouched/computed/readonly keys is a FAIL — one atomic save, changed keys only", + "an inline or form save that omits the If-Match header (an unguarded write) is a FAIL — the OCC token must ride or a concurrent overwrite goes undetected" + ], + "traps": [ + "hydration-race", + "automation-input", + "shared-browser-tab", + "stale-console-bundle" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-form/src/occSave.test.tsx (409 handling + overwrite retry); e2e/live/inline-edit-polish-2572.spec.ts (single save bar / atomic inline save)" + }, + "source": [ + "objectui: packages/plugin-detail/src/ConcurrentUpdateDialog.tsx (Reload/Overwrite UX), packages/plugin-form/src/occSave.tsx (If-Match → 409, conflict dialog, re-key overwrite)", + "objectui: packages/plugin-detail/src/InlineEditSaveBar.tsx + packages/react/src/context/InlineEditContext.tsx (one save bar, changed-keys draft, ifMatch)", + "framework: packages/rest/src/rest-server.ts (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts updateData (If-Match / expectedVersion)", + "PENDING-GAPS §C concurrent-edit-conflict + §E1 inline-edit atomic two-surface (objectui#2542/2549/2604); cross-ref records-forms.crud-roundtrip" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test OCC conflict (409 + dialog, reload/overwrite) and fold in the §E1 inline-edit atomic two-surface behavior (ONE save bar / ONE PATCH / ifMatch)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.record-discussion-mentions", + "title": "Record discussion: an @mention comment reconciles optimistically, persists to sys_comment, interleaves with activity, and pings the mentioned user's bell", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) — the comment author", + "a second signed-up user — the @mention TARGET (sys_user rows come from sign-up, not seeds)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a showcase object with feeds enabled (default — enable.feeds !== false) so the discussion panel mounts and sys_comment writes are accepted (packages/spec/src/data/object.zod.ts feeds default true; plugin-audit enforceFeedsCapability)", + "a record to comment on (e.g. a seeded showcase_task or showcase_account)" + ], + "knownGaps": [ + "the @mention TARGET and the bell/notification clauses need a SECOND signed-up user — a fresh single-user boot cannot exercise them; record those clauses blocked(fixture) and run the optimistic-reconcile + sys_comment + activity-interleave clauses as the single author" + ] + }, + "steps": [ + "as admin, open a record detail with the discussion panel (feeds enabled); screenshot", + "type a comment containing an @mention (the mention autocomplete offers mentionSuggestions); post it", + "observe the OPTIMISTIC comment row appear immediately; capture the sys_comment create", + "after the feed refetch, confirm the optimistic row reconciles with the server row (same id → union-by-id merge, no duplicate, no phantom)", + "read sys_comment over the API: GET /api/v1/data/sys_comment?$filter for the record's thread_id, ordered by created_at asc — the posted comment is present", + "perform an attributable change on the record (e.g. an edit) and confirm the resulting sys_activity row interleaves into the SAME feed, oldest-first", + "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)" + ], + "acceptance": [ + { + "clause": "the posted comment PERSISTS to sys_comment (thread_id-scoped, created_at-ordered) and the API read returns it — a success toast is not the oracle, the row is", + "oracle": "api", + "verify": "GET /api/v1/data/sys_comment filtered by the record's thread_id returns the comment with its body (RecordDetailView sys_comment fetch: dataSource.find('sys_comment', {$filter:{thread_id}, $orderby:{created_at:'asc'}}))", + "evidence": "the sys_comment read" + }, + { + "clause": "the OPTIMISTIC row reconciles with the server row by id — no duplicate, no phantom (the create uses the same id the refetch returns; union-by-id keeps one)", + "oracle": "dom", + "verify": "after screenshot, the feed shows exactly ONE row for the posted comment across the post→refetch transition (RecordDetailView mergeFeedRows: Map by String(id), server copy wins on the same key)", + "evidence": "post + post-refetch feed screenshots" + }, + { + "clause": "sys_activity rows INTERLEAVE into the same discussion feed, oldest-first, alongside the comments (one feed, two tables)", + "oracle": "dom", + "verify": "after an attributable change, its sys_activity row appears in time order among the comment rows (mergeFeedRows sorts by createdAt; recordActivityFeed.ts maps sys_activity.type → FeedItemType)", + "evidence": "the interleaved feed screenshot + the sys_activity read" + }, + { + "clause": "the mentioned user's bell gains the notification with a WORKING deep link to the record, and unreadCount increments; mark-as-read / mark-all-read drop the count", + "oracle": "dom", + "verify": "as the mentioned user, the bell shows the new mention notification, its link navigates to the commented record, and reading it decrements unreadCount (collaboration/useMentionNotifications)", + "evidence": "bell screenshots before/after + the deep-link navigation" + }, + { + "clause": "notifications are RECIPIENT-scoped both sides: only the @mentioned user's bell gains it (addNotification is gated recipientId === currentUserId); a non-mentioned user's bell does not", + "oracle": "dom", + "verify": "the mentioned user sees it; a second, non-mentioned session does not (useMentionNotifications recipient gate)", + "evidence": "both users' bells" + } + ], + "negative": [ + "an optimistic comment that stays as a SECOND row after the server row lands (dupe) or vanishes entirely (phantom) is a FAIL — mergeFeedRows exists to keep exactly one", + "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" + ], + "traps": [ + "hydration-race", + "automation-input", + "seed-data-thin" + ], + "source": [ + "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 (enable.feeds default true → FEEDS_DISABLED); PENDING-GAPS §B record-discussion-mentions" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test the record discussion: optimistic reconcile, sys_comment persistence, activity interleave, recipient-scoped mention bell", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.grid-personalization", + "title": "Grid personalization persists across reload: column resize/reorder/pin, row-height, group-by (with API-true totals), row-color", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_task grid view with a groupable field (status) and a summarizable field (estimate_hours) — examples/app-showcase/src/ui/views/task.view.ts; 10 seeded tasks across statuses (examples/app-showcase/src/data/seed/index.ts)" + ] + }, + "variants": [ + "column-resize", + "column-reorder", + "column-pin", + "row-height", + "group-by", + "row-color" + ], + "steps": [ + "open showcase_task grid; screenshot", + "resize a column, reorder two columns, pin a column, and set a row-height mode (compact/short/medium/tall/extra_tall)", + "reload; confirm the columnState (widths, order, pinned) and row-height restored from localStorage grid-columns-showcase_task-<viewId> (or the persisted view override via dataSource.updateViewConfig)", + "group-by status; confirm per-group headers appear and each group shows its summarizeField (estimate_hours) total; clear grouping and confirm the headers are removed cleanly", + "cross-check one group's total against a direct API aggregate: GET /api/v1/data/showcase_task grouped client-side from the raw rows", + "set a row-color rule; confirm rows paint by the rule (useRowColor)", + "reload once more; confirm every personalization survives; capture a screenshot per variant" + ], + "acceptance": [ + { + "clause": "PER-VARIANT: column resize + reorder + pin persist across a reload — the columnState (widths/order/pinned) restores from localStorage (grid-columns-<object>-<view>) or a persisted view override; a repaint that does NOT survive reload fails that variant", + "oracle": "dom", + "verify": "after a screenshot confirms render, the reloaded grid shows the same widths/order/pinned column (ObjectGrid columnState priority: props override > localStorage > empty; saveColumnState writes both)", + "evidence": "before/after-reload screenshots + the storage value" + }, + { + "clause": "row-height mode persists across reload (one of compact/short/medium/tall/extra_tall)", + "oracle": "dom", + "verify": "the reloaded grid renders at the chosen density (ObjectGrid rowHeightMode)", + "evidence": "before/after screenshots" + }, + { + "clause": "group-by renders per-group headers and CLEARS cleanly, and the grouped totals MATCH a direct API aggregate of the grouping/summary field — grouping summarizes the real rows, not the rendered page", + "oracle": "api", + "verify": "compare a group's summarizeField total against GET /api/v1/data/showcase_task aggregated client-side (ObjectGrid grouping + useColumnSummary); clearing grouping removes the headers", + "evidence": "board screenshot + the aggregate check" + }, + { + "clause": "row-color rules paint rows by the rule (useRowColor), and survive a reload with the rest of the personalization", + "oracle": "screenshot", + "verify": "after reload the colored rows match the rule against the seeded values", + "evidence": "post-reload screenshot" + }, + { + "clause": "the column-state store is per-browser and deliberately NOT user-scoped (cosmetic), UNLIKE the runtime filter store — so a shared column layout across accounts on one browser is by-design, not a leak to fail on", + "oracle": "dom", + "verify": "the key is grid-columns-<object>[-<view>] with no user id (listFilterStorage.ts documents the deliberate asymmetry: widths cosmetic, filter values sensitive)", + "evidence": "the storage key" + } + ], + "negative": [ + "a personalization that repaints but does NOT survive reload is a FAIL — the persistence, not the repaint, is the contract", + "a group total that disagrees with the API aggregate is a FAIL — the grouping must summarize the real matching rows, not just the rendered page" + ], + "traps": [ + "hydration-race", + "stale-console-bundle" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-grid/src/__tests__/{groupedPagination,groupedBooleanLabel,inlineEditPersistence}.test.tsx" + }, + "source": [ + "objectui: packages/plugin-grid/src/ObjectGrid.tsx (columnState + saveColumnState localStorage grid-columns-*, rowHeightMode, schema.grouping, useRowColor, useColumnSummary)", + "objectui: packages/components/src/renderers/complex/data-table.tsx (the underlying table)", + "objectui: packages/app-shell/src/views/listFilterStorage.ts (documents grid-columns-* is NOT user-scoped by design)", + "examples/app-showcase/src/ui/views/task.view.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test grid personalization persistence (columns/row-height/grouping/row-color) with API-true grouping totals", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.saved-view-management", + "title": "Admin saved-view lifecycle: create kanban via dialog, rename/set-default/pin/delete each hit the meta overlay and survive reload; non-admin affordances absent and server-refused", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a non-admin user (e.g. bound to showcase_member_default) — sign one up; sys_user rows cannot be seeded" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a showcase object with a groupable field for the kanban groupByField (showcase_task, status) — examples/app-showcase/src/ui/views/task.view.ts", + "the console Create View / Manage Views dialogs (objectui CreateViewDialog.tsx, ManageViewsDialog.tsx)" + ], + "knownGaps": [ + "the non-admin refusal clauses need a signed-up non-admin user bound to a member set — record them blocked(fixture) on a single-user boot" + ] + }, + "variants": [ + "create-kanban", + "rename", + "set-default", + "pin", + "delete" + ], + "steps": [ + "as admin, open showcase_task; open the Create View dialog; choose kanban; set groupByField = status; create — capture PUT /api/v1/meta/view/<name> (meta.saveItem) carrying {type:'kanban', kanban:{groupByField:'status'}}", + "reload; confirm the new kanban view is live and groups by status", + "rename the view via Manage Views; capture the PUT; reload confirms the new label", + "set the view as default (unsets the prior default); capture the PUT(s); reload confirms the default moved", + "pin the view; capture the PUT (isPinned); reload confirms it pinned", + "delete the view; capture DELETE /api/v1/meta/view/<name>; reload confirms it is gone", + "as the non-admin, open the same object; confirm the create-view and manage-views (rename/default/pin/delete) affordances are ABSENT", + "as the non-admin, forge a direct PUT /api/v1/meta/view/<name>; capture the refusal" + ], + "acceptance": [ + { + "clause": "creating a kanban view via CreateViewDialog persists a PUT /api/v1/meta/view/<name> carrying the type and its config (kanban.groupByField), and the view is LIVE after reload — a local repaint that never reached the overlay is not enough", + "oracle": "network", + "verify": "captured PUT /api/v1/meta/view/<name> (data-objectstack createView → client.meta.saveItem('view', name, spec); framework route PUT /api/v1/meta/:type/:name, client meta.saveItem); reload re-reads it via listViews", + "evidence": "the PUT payload + post-reload view list" + }, + { + "clause": "rename / set-default / pin / delete each issue their meta write and are live after reload; set-default UNSETS the prior default (updateView isDefault flips all others off)", + "oracle": "network", + "verify": "one PUT per rename/pin/isDefault (updateView read-merge-write) and a DELETE /api/v1/meta/view/<name> for delete (deleteView → meta.deleteItem); reload reflects each", + "evidence": "the four traces + post-reload states" + }, + { + "clause": "the non-admin lacks the mutation affordances: the create-view control and the Manage Views rename/default/pin/delete actions are ABSENT (ManageViewsDialog suppresses mutation affordances for system/read-only)", + "oracle": "dom", + "verify": "after a screenshot confirms the object opened, the DOM shows no create-view / manage mutation controls for the non-admin", + "evidence": "the non-admin screenshot" + }, + { + "clause": "the SERVER is the boundary: a forged PUT /api/v1/meta/view/<name> by the non-admin is refused (4xx) — UI absence alone is courtesy", + "oracle": "api", + "verify": "the direct non-admin PUT returns a 403-class refusal and no overlay view is created (RUNNER rule 4, ADR-0057 D10)", + "evidence": "the forged-request refusal" + } + ], + "negative": [ + "a view mutation that repaints locally but does NOT survive reload (never reached the meta overlay) is a FAIL", + "a non-admin able to persist a meta view via a forged PUT is a FAIL — both sides of the gate", + "set-default that leaves TWO views flagged default (the prior one not unset) is a FAIL" + ], + "traps": [ + "hydration-race", + "wrong-persona" + ], + "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)", + "PENDING-GAPS §C saved-view-management; cross-ref studio-authoring.view-authoring-live (distinct — this is the runtime end-user/admin lifecycle)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test the saved-view lifecycle (create-kanban/rename/default/pin/delete) against the meta overlay, with the non-admin both-sides gate", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.bulk-select-all-matching", + "title": "Select-all-matching covers every matching id across pages (server total, not the rendered page); clear resets both selection sources", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a list whose filtered result exceeds one page: showcase_contact filtered to Northwind's account has 26 rows (2 named + 24 'Prospect NN'), authored to exceed a page (examples/app-showcase/src/data/seed/index.ts, objectui#2711)", + "a bulk action registered on the object so the BulkActionBar and its aggregate/per-record dispatch are exercisable (objectui plugin-grid BulkActionBar)" + ] + }, + "steps": [ + "open the showcase_contact list filtered so the match set exceeds one page (e.g. account = Northwind → 26 rows); screenshot; note page shows N rows, count bar shows server total M (M > N)", + "select the whole visible page; the cross-page banner appears offering 'Select all M matching'", + "click select-all-matching; the banner reads 'All M matching records are selected'", + "dispatch a bulk action; capture the requests — resolveBulkRows fans out a paged find (dropping $top/$skip, paging 500) collecting every matching id ACROSS pages", + "cross-check the collected id count against a direct filtered API count: GET /api/v1/data/showcase_contact?$filter=...&$top=0 (or the response total)", + "confirm an AGGREGATE action carries every matching id in params._selectedIds; a per-record action fans out one dispatch per matching id", + "click clear; confirm both selection sources reset (the toolbar empties AND the row checkboxes clear)" + ], + "acceptance": [ + { + "clause": "the cross-page banner reports the SERVER total (totalMatching from the find result.total), not the rendered page count — and only appears when the whole page is selected and more matching rows exist off-screen", + "oracle": "network", + "verify": "the banner's count equals the data request's response total (BulkActionBar totalMatching; ObjectGrid captures result.total into totalMatching)", + "evidence": "banner screenshot + the find response total" + }, + { + "clause": "select-all-matching resolves EVERY matching id across pages: the dispatched set equals a direct filtered API count, not just the visible page (paged fan-out with $top/$skip dropped, capped at 5000)", + "oracle": "api", + "verify": "count the ids handed to the executor (resolveBulkRows collected set) vs GET /api/v1/data/showcase_contact filtered total; they match (up to the HARD_CAP)", + "evidence": "the collected-id count + the filtered API count" + }, + { + "clause": "an AGGREGATE bulk action issues ONE dispatch carrying the full matching id set in params._selectedIds; a per-record action fans out one dispatch per matching id", + "oracle": "network", + "verify": "count captured POSTs against the match-set size per mode (ObjectGrid dispatchBulkAction → resolveBulkRows → params._selectedIds)", + "evidence": "the dispatch trace(s)" + }, + { + "clause": "clear resets BOTH selection sources — the toolbar selectedRows AND the data-table row checkboxes (selectionResetKey) — so no ticked rows are stranded with no toolbar to act on them (#3056)", + "oracle": "dom", + "verify": "after clear, both the toolbar and the row checkboxes are empty (resetSelection: setSelectedRows([]) + setSelectAllMatching(false) + bump selectionResetKey)", + "evidence": "post-clear screenshot" + }, + { + "clause": "the fan-out is bounded (HARD_CAP 5000): a match set beyond the cap is handled coherently (truncated to the cap, not silently claiming to cover everything) — note the weakness in evidence where the set is large", + "oracle": "network", + "verify": "the collected set never exceeds 5000; where the match set is larger, the run records the cap rather than asserting full coverage", + "evidence": "the collected-set size vs the server total" + } + ], + "negative": [ + "a 'select all matching' that only acts on the RENDERED page (ids = page rows, not the server match set) is THE FAIL this item extends list-view-capabilities (page-local bulk) to catch", + "a banner total that disagrees with the filtered API count is a FAIL", + "a clear that leaves the row checkboxes ticked while the toolbar empties (or vice versa) is a FAIL (#3056 drift)" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/plugin-grid/src/__tests__/{objectBulkActionDispatch,BulkActionBar,bulkActionRefresh}.test.tsx" + }, + "source": [ + "objectui: packages/plugin-grid/src/ObjectGrid.tsx (selectAllMatching, resolveBulkRows paged fan-out + HARD_CAP, resetSelection #3056, totalMatching), packages/plugin-grid/src/components/BulkActionBar.tsx (cross-page banner)", + "examples/app-showcase/src/data/seed/index.ts (Northwind 26-contact fixture, objectui#2711)", + "PENDING-GAPS §C bulk-select-all-matching; cross-ref records-forms.list-view-capabilities (bulk-actions, page-local)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test cross-page select-all-matching: server total, across-pages id coverage vs a filtered API count, clear-resets-both (extends the page-local bulk in list-view-capabilities)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.lookup-picker-create-new", + "title": "Lookup picker quick-create: a user-facing zero-hit lookup opens the referenced create form and adopts the new id; system references offer no quick-create", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123) — holds create on the referenced object" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a user-facing lookup with allowCreate: showcase_business_unit.parent (Field.lookup('showcase_business_unit', {allowCreate:true})) or showcase_category.parent (allowCreate:true) — examples/app-showcase/src/data/objects/{business-unit,category}.object.ts", + "a system/user-directory reference for the negative side (any sys_/cloud_/ai_ reference, or a user/users directory field) — the picker must NOT offer quick-create there" + ] + }, + "steps": [ + "open New Business Unit (or New Category); open the parent lookup picker", + "type a search term with ZERO hits; confirm the picker issues a SCOPED server find against the referenced object and shows a Create-new affordance (labelled with the typed name)", + "click create-new; the referenced object's FULL create form opens (via the ActionProvider modal); fill required fields and save", + "confirm the picker ADOPTS the newly created id and the parent form's field now holds it", + "save the parent; re-read the parent over the API — its reference resolves to the new id", + "type a search WITH hits; confirm each keystroke issues a scoped server request (not client filtering of a preloaded set)", + "open a lookup to a system/user-directory reference (sys_/cloud_/ai_ or user/users); confirm NO create-new affordance is offered" + ], + "acceptance": [ + { + "clause": "a zero-hit USER-FACING lookup offers create-new, opening the referenced object's FULL create form (allowCreate is default-on for user-facing relations so a fresh app is not a dead end)", + "oracle": "dom", + "verify": "after a screenshot confirms the picker, the create-new control is present and clicking it opens the referenced object's create form (LookupField isUserFacingReference + handleCreateNew)", + "evidence": "picker screenshot + the create-form open" + }, + { + "clause": "saving the create form makes the picker ADOPT the new id, and the parent save re-reads with the reference resolving to that exact id", + "oracle": "api", + "verify": "GET the parent after save; the lookup field holds the created child's real id (not a placeholder/stale id)", + "evidence": "the parent re-read + the child id" + }, + { + "clause": "typed search issues a SCOPED server find against the referenced object (server-side search, not client-side filtering of a preloaded set)", + "oracle": "network", + "verify": "captured requests carry the search scoped to the referenced object (RecordPickerDialog → DataSource.find with the query)", + "evidence": "the search request traces" + }, + { + "clause": "system references offer NO quick-create: a lookup to a sys_/cloud_/ai_ object or the user/users directory shows no create-new affordance — you must not mint plumbing rows inline", + "oracle": "dom", + "verify": "after screenshot, the system-reference picker has no create-new control (SYSTEM_REFERENCE_RX /^(sys_|cloud_|ai_)/ + USER_DIRECTORY_REFS {user,users}; sys_user matches the RX)", + "evidence": "the system-reference picker screenshot" + } + ], + "negative": [ + "a create-new offered on a sys_/cloud_/ai_ or user-directory reference is a FAIL — isUserFacingReference excludes them by design", + "a picker that filters a preloaded CLIENT set instead of issuing a scoped server search is a FAIL — the unscoped fetch is the defect", + "the parent adopting a stale/placeholder id instead of the created row's real id is a FAIL" + ], + "traps": [ + "hydration-race", + "automation-input" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/fields/src/widgets/{LookupField.dependsOn,RecordPickerDialog.filterOptions}.test.tsx (adjacent picker pins)" + }, + "source": [ + "objectui: packages/fields/src/widgets/LookupField.tsx (allowCreate default-on, SYSTEM_REFERENCE_RX / USER_DIRECTORY_REFS, isUserFacingReference, handleCreateNew), packages/fields/src/widgets/RecordPickerDialog.tsx (scoped find)", + "examples/app-showcase/src/data/objects/business-unit.object.ts + category.object.ts (parent allowCreate:true)", + "PENDING-GAPS §B lookup-picker-create-new" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test lookup quick-create: create-new opens the referenced form + id adoption + parent re-read, scoped search, and the sys_/user-directory no-quick-create both-sides", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.record-edit-undo", + "title": "Record edit undo: toast Undo / Ctrl+Z restores the prior value through the API and logs a revert; undo after a concurrent change must not silently clobber", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)", + "a second session (for the concurrent-clobber probe)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a writable record and an undoable edit path (an inline edit or an action flagged undoable) — objectui react/src/hooks/useGlobalUndo.ts + RecordDetailView toast Undo action; core globalUndoManager" + ] + }, + "steps": [ + "open a record; make an undoable edit to one field; confirm the success toast shows an Undo button", + "click Undo (or press Ctrl+Z); capture the resulting update; re-read the record over the API and confirm the prior value is restored", + "open the History/audit and confirm a revert entry was added (a forward write, not a silent rollback)", + "press Ctrl+Shift+Z (redo); confirm the edited value is restored", + "CONCURRENT-CLOBBER probe: in session A make the undoable edit; in session B change the SAME field and save; back in A, click Undo", + "observe and FLAG whether A's undo carries an OCC guard or silently overwrites B's value" + ], + "acceptance": [ + { + "clause": "undo restores the prior value through the dataSource (a REAL write) and the API re-read shows it — not merely a local UI rollback", + "oracle": "api", + "verify": "after Undo, GET the record; the field equals its pre-edit value (useGlobalUndo executeOp → dataSource.update(objectName, recordId, op.undoData))", + "evidence": "the re-read + the undo update trace" + }, + { + "clause": "both the toast Undo button and Ctrl+Z trigger undo, and Ctrl+Shift+Z redoes — the same globalUndoManager stack", + "oracle": "dom", + "verify": "the toast action and the keyboard shortcut both restore the prior value; redo re-applies the edit (RecordDetailView toast action onClick undoCtl.undo; useGlobalUndo Ctrl+Z / Ctrl+Shift+Z)", + "evidence": "screenshots of both paths" + }, + { + "clause": "the revert is auditable: the History/audit gains a NEW entry for the undo (undo is a forward write, not a hidden state reset)", + "oracle": "api", + "verify": "after screenshot of the History tab, the audit/read shows the revert as its own update", + "evidence": "the History entries + audit read" + }, + { + "clause": "CONCURRENT-CLOBBER (observe + flag): the undo path issues a bare dataSource.update with NO ifMatch, so an undo after a concurrent edit landed in between will overwrite it — verify the observed behavior and record a silent clobber as a finding, not a silent pass", + "oracle": "api", + "verify": "in the two-session probe, read whether A's undo carries an If-Match / triggers a 409, or silently overwrites B's value (useGlobalUndo executeOp: update(op.objectName, op.recordId, data) — no OCC token)", + "evidence": "the undo request headers + B's value before/after A's undo" + } + ], + "negative": [ + "an undo that only repaints the field but does NOT persist (no API write, or the re-read still shows the edited value) is a FAIL — the restore must round-trip the server", + "an undo silently overwriting a concurrent edit with no conflict signal is the behavior to FLAG as an expected-risk finding (the undo path is unguarded) — recording it as a clean pass would hide a lost-update hole" + ], + "traps": [ + "automation-input", + "hydration-race", + "shared-browser-tab" + ], + "automated": { + "kind": "unit", + "ref": "objectui: packages/core/src/actions/__tests__/UndoManager.test.ts (undo/redo stack)" + }, + "source": [ + "objectui: packages/react/src/hooks/useGlobalUndo.ts (executeOp, undo/redo, Ctrl+Z/Ctrl+Shift+Z), packages/app-shell/src/views/RecordDetailView.tsx (success-toast Undo action), @object-ui/core globalUndoManager", + "PENDING-GAPS §C record-edit-undo (concurrent-clobber flag)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test edit undo: API-true restore via toast/Ctrl+Z, auditable revert, and the unguarded concurrent-clobber observation", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.import-job-undo-cancel", + "title": "Async import job: terminal state + per-row results, undo removes exactly the imported rows, cancel mid-job leaves a coherent partial", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "an import-job-capable client wired into the console AND an object with an async-import route (framework packages/rest/src/rest-route-ledger.ts: POST /api/v1/data/:object/import/jobs, /import/jobs/:jobId/{cancel,undo,results})", + "the objectui import specs, which self-gate on IMPORT_CONSOLE_LIVE=1 (console) or a reachable import harness /live.html" + ], + "knownGaps": [ + "the async import + undo/cancel path is gated: e2e/import-console/import-console-undo.spec.ts skips unless IMPORT_CONSOLE_LIVE=1 with an import-job-capable client wired; e2e/import-harness/import-undo.spec.ts skips unless the harness origin serves /live.html — on a stock showcase boot these do not run, so record blocked(fixture)" + ] + }, + "blocked": { + "by": "fixture", + "ref": "IMPORT_CONSOLE_LIVE / import-harness gate — objectui e2e/import-console/import-console-undo.spec.ts self-skips unless IMPORT_CONSOLE_LIVE=1 and an import-job-capable client is wired; PENDING-GAPS §C import-job-undo-cancel" + }, + "steps": [ + "run the pinned specs gated on: IMPORT_CONSOLE_LIVE=1 pnpm exec playwright test e2e/import-console/import-console-undo.spec.ts (and the harness twin e2e/import-harness/import-undo.spec.ts); capture the suite output as the primary evidence", + "when the gate is available: POST /api/v1/data/:object/import/jobs with a marker-tagged CSV; poll GET /api/v1/data/import/jobs/:jobId until terminal; GET /api/v1/data/import/jobs/:jobId/results for per-row results", + "count marker rows via a filtered GET before and after the import; then POST /api/v1/data/import/jobs/:jobId/undo and re-count", + "GET /api/v1/data/import/jobs and confirm the job list distinguishes undoable/non-undoable and reverted state ({ jobId, undoable, revertedAt, createdAt }) so the fresh job is findable", + "start a fresh import and POST /api/v1/data/import/jobs/:jobId/cancel mid-job; inspect the row set for coherence" + ], + "acceptance": [ + { + "clause": "an async import creates an UNDOABLE job that reaches a terminal state and exposes per-row results", + "oracle": "test", + "verify": "the job list shows { jobId, undoable:true, revertedAt:null } and GET /import/jobs/:jobId/results returns per-row outcomes (pinned by import-console-undo.spec.ts / import-undo.spec.ts under their gate)", + "evidence": "the gated spec output + the jobs/results reads" + }, + { + "clause": "undo removes EXACTLY the imported rows — a marker filter count returns to its pre-import value, with no collateral deletion of pre-existing rows", + "oracle": "api", + "verify": "filtered GET marker count: pre-import == post-undo, and post-import == pre-import + imported count (the specs assert record counts at the backend on both sides)", + "evidence": "the three marker counts (pre / post-import / post-undo)" + }, + { + "clause": "cancel mid-job yields a COHERENT partial: whole rows committed or none, never a half-written row (partial row / dangling FK)", + "oracle": "api", + "verify": "after cancel, the committed rows are complete records; no torn row exists; the job reports the cancelled/partial state", + "evidence": "the post-cancel row read + job state" + }, + { + "clause": "the job list distinguishes undoable/non-undoable and reverted state so a run can find the fresh undoable job it created", + "oracle": "api", + "verify": "GET /api/v1/data/import/jobs returns entries with { jobId, undoable, revertedAt, createdAt } (the specs filter on j.undoable && !j.revertedAt)", + "evidence": "the jobs list read" + } + ], + "negative": [ + "an undo that deletes MORE than the imported rows (or leaves some behind) is a FAIL — exactly the imported set, no more, no less (the async threshold vs undo-capture mismatch is the exact bug the specs exist for)", + "a cancel that leaves half-written rows (partial row, dangling FK) is a FAIL — coherent partial only", + "reporting the item PASS off a skipped (gated-out) spec is a FAIL — a skip is blocked(fixture), never a green tick" + ], + "traps": [ + "seed-data-thin", + "dispatcher-vs-hono-route" + ], + "automated": { + "kind": "e2e", + "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])", + "objectui: e2e/import-console/import-console-undo.spec.ts, e2e/import-harness/import-undo.spec.ts", + "PENDING-GAPS §C import-job-undo-cancel" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — deep-test async import jobs (terminal + per-row results, exact-set undo, coherent cancel); blocked(fixture) on the IMPORT_CONSOLE_LIVE / harness gate, pinned to the self-gating specs", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.object-hook-lifecycle", + "title": "Object lifecycle hooks fire on the write path with the right timing, gate, async and error semantics — driven over /api/v1/data/*, oracled by the record effect and the log line", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "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", + "a real running server: hooks fire in the ObjectQL engine on the live /api/v1/data/* route, never on a simulated dispatch" + ], + "knownGaps": [ + "afterInsert and afterDelete have NO fixture hook — the four seeded hooks cover beforeInsert, beforeUpdate and afterUpdate only. Exercise these two events with a scratch log hook or record the variant knownGap; do not fake coverage.", + "beforeDelete has NO seeded showcase hook — the abort-blocks-delete contract is pinned only by the dogfood hef_ref_guard fixture (packages/qa/dogfood/test/hook-error-format.dogfood.test.ts), a test-only stack, not the showcase app; drive beforeDelete via a scratch hook or cite that pin.", + "priority ordering (lowest-first on the SAME object+event) is not fixture-covered: no two showcase hooks share an object+event (normalize is showcase_task/before*, audit is showcase_task/afterUpdate — different events), so ordering needs a scratch pair of hooks on one object+event with distinct priorities.", + "onError:'abort' ROLLBACK and onError:'log' SUPPRESSION are only observable with a hook that THROWS — the four fixtures never throw (their trim/stamp/log bodies always succeed). The abort-rollback, log-tolerate and fail-closed-condition clauses each require a scratch throwing hook (or the cited dogfood abort pin).", + "the async audit/warn lines land AFTER the HTTP write resolves (fire-and-forget runs once the engine has moved on) — a log read taken before the async body executes shows no line; settle before judging any no-fire." + ] + }, + "variants": [ + "beforeInsert — FIXTURE: showcase_stamp_inquiry_defaults (stamps status/source on a new inquiry) and showcase_normalize_task_title (also trims on insert)", + "beforeUpdate — FIXTURE: showcase_normalize_task_title (trims title on every task update)", + "afterUpdate — FIXTURE: showcase_audit_task_completion (task done-transition audit) and showcase_warn_over_budget (project over-budget warn)", + "afterInsert — SCRATCH/knownGap: no showcase hook targets afterInsert; add a scratch afterInsert log hook or record knownGap", + "beforeDelete — SCRATCH/knownGap: no seeded showcase hook; the abort-blocks-delete shape is pinned only by the dogfood hef_ref_guard fixture (hook-error-format.dogfood.test.ts)", + "afterDelete — knownGap: no showcase hook and no dogfood pin; add a scratch afterDelete log hook to exercise", + "multi-event targeting — FIXTURE: showcase_normalize_task_title lists events ['beforeInsert','beforeUpdate'] — ONE hook fires on two write kinds (hook-binder registers per-event × per-object)", + "CEL condition — two-root transition semantics (#4770/#4784) — FIXTURE: showcase_audit_task_completion `previous.done != true && record.done == true`; since #4770 `record` is the record's STATE (stored ⊕ payload, total over declared fields), so `record.done == true` alone is true on every edit of an already-done row — `previous` is what makes 'just became done' expressible", + "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)", + "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", + "condition fails CLOSED (#4775) — SCRATCH: an unevaluable/uncompilable condition ABORTS the operation and is NOT softened by onError:'log' nor fire-and-forgotten (the gate runs OUTSIDE both); add a scratch hook with a broken condition (unit-pinned in hook-wrappers.ts)" + ], + "steps": [ + "boot showcase isolated; sign in as seeded admin; confirm the four hooks actually registered before asserting any no-fire — GET /api/v1/meta/types/hook (or the boot log) lists showcase_normalize_task_title / showcase_stamp_inquiry_defaults / showcase_audit_task_completion / showcase_warn_over_budget (a hook that never bound fakes every no-fire — seed-data-thin)", + "stamp defaults (beforeInsert): POST /api/v1/data/showcase_inquiry {name:'os-qa-<runid>'} OMITTING status AND source; re-read via GET and confirm source == 'web' — the hook is the SOLE producer of that value (the source field carries no default), so a correct read proves the hook fired; status == 'new' corroborates but is NOT hook-attributable alone (the 'new' status option is default:true)", + "title trim (beforeInsert + beforeUpdate): POST /api/v1/data/showcase_task with title ' os-qa-<runid> ' (leading/trailing spaces) and valid required fields; re-read → title == 'os-qa-<runid>' (insert trim); then PATCH the same row's title to another padded value and re-read → trimmed again (update trim); ONE multi-event hook covered both", + "completion audit — FIRES (transition): PATCH /api/v1/data/showcase_task/<Build homepage id> {done:true} (previous done:false); after the HTTP response resolves, settle briefly (the audit is async fire-and-forget, it runs after the write returns), then read the server log for 'task completed: Build homepage'", + "completion audit — DOES NOT FIRE (two-root proof): PATCH an already-done task (<App wireframes id>, done:true) changing ONLY priority (medium→high), leaving done untouched; settle; confirm NO new 'task completed: App wireframes' line — previous.done == true makes the transition condition false even though record.done == true", + "over-budget warn — FIRES: PATCH /api/v1/data/showcase_project/<Data Platform id> {spent:650000} (budget 600000 → over budget, but ≤ 720000 so the spent_within_budget rule still permits the write); settle; read the log for 'project over budget: Data Platform (650000 / 600000)'", + "over-budget warn — condition gate (under budget): PATCH 'Website Relaunch' {spent:70000} (still under its 150000 budget); settle; confirm NO warn line (record.spent > record.budget is false) — the same partial write proves `record` merges the stored budget it never sent", + "abort rollback (scratch): register a scratch beforeInsert hook whose body throws, onError:'abort', on a scratch object (or showcase_task); POST a row; capture the refusal and confirm a filtered GET count stays 0 (the write rolled back, no orphan) — cross-check the dogfood hef_ref_guard beforeDelete pin", + "onError:'log' tolerate (scratch): register a scratch async afterUpdate hook whose body throws, onError:'log'; trigger it with a real update; confirm the triggering write STILL landed (re-read shows the change) and the server logged '[hook] async handler error (fire-and-forget)' — a suppressed failure, not a rollback", + "priority ordering (scratch): register two scratch hooks on ONE object+event with priorities 10 and 90, each logging its own name; issue one write; confirm the log order is the priority-10 hook THEN the priority-90 hook (lower runs first)" + ], + "acceptance": [ + { + "clause": "beforeInsert stamps server-controlled defaults: an inquiry POSTed with NO source reads back source == 'web' — the hook is the sole producer of that value (the field has no default), so a correct read PROVES the hook ran; status == 'new' corroborates but is not hook-attributable alone (the 'new' option is default:true)", + "oracle": "api", + "verify": "POST /api/v1/data/showcase_inquiry omitting source/status → GET the created row shows source 'web' (and status 'new'); pinned for the anonymous public-form path by packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts", + "evidence": "the POST payload + the re-read JSON" + }, + { + "clause": "one multi-event hook trims on BOTH write kinds: a padded title ' X ' reads back 'X' after the create (beforeInsert) and again after an update (beforeUpdate) — events:['beforeInsert','beforeUpdate'] on a single hook", + "oracle": "api", + "verify": "re-reads after the padded POST and after the padded PATCH both return the trimmed title", + "evidence": "the two re-reads + the two write payloads" + }, + { + "clause": "the afterUpdate audit fires on the COMPLETING update: PATCH done:false→true emits 'task completed: <title>' through the ['log'] capability — the async, retry-policied, priority-90 hook", + "oracle": "log", + "verify": "after settling for the fire-and-forget async body, the server log carries the line naming the task just completed", + "evidence": "the log excerpt + the triggering PATCH" + }, + { + "clause": "the audit does NOT fire on a NON-transition edit of an already-done task — previous.done != true is false though record.done == true; this is the two-root transition semantics (#4770/#4784), and a fire here would prove the condition collapsed to a bare `record.done == true` state test", + "oracle": "log", + "verify": "PATCH only priority on a done task; a before/after diff of the server log shows NO new 'task completed' line for it", + "evidence": "the before/after log diff around the non-transition PATCH" + }, + { + "clause": "the afterUpdate over-budget warn fires when record.spent > record.budget: PATCH spent above budget (but ≤ 120%, so the spent_within_budget rule permits the write) logs 'project over budget: <name> (<spent> / <budget>)'", + "oracle": "log", + "verify": "the server log carries the warn naming the project and the two numbers after the over-budget PATCH settles", + "evidence": "the log excerpt + the PATCH" + }, + { + "clause": "the condition's `!= null` guard proves `record` is stored ⊕ payload (total over declared fields), NOT the bare patch: an over-budget PATCH that touches ONLY spent still reads budget from the stored row and fires; an under-budget PATCH emits no warn", + "oracle": "log", + "verify": "over-budget spent-only PATCH → warn line; under-budget PATCH → no line; the fire from a partial write is the proof budget was merged from storage", + "evidence": "both log states keyed to the two PATCHes" + }, + { + "clause": "onError:'abort' on a THROWING before* hook rolls the write back: the POST is refused and a filtered GET count stays 0 — no orphan row lands", + "oracle": "api", + "verify": "scratch throwing beforeInsert; POST → error envelope; before/after filtered count both 0 (mirrors the dogfood hef_ref_guard beforeDelete → REST error-body pin in hook-error-format.dogfood.test.ts)", + "evidence": "the refusal response + the before/after counts" + }, + { + "clause": "onError:'log' on a THROWING async afterUpdate hook does NOT roll the triggering write back: the PATCH persists (re-read shows the change) and the failure is only logged — a fire-and-forget failure can never un-commit a write the engine already resolved", + "oracle": "api", + "verify": "scratch throwing async hook; the PATCH's re-read shows the new value AND the server log carries '[hook] async handler error (fire-and-forget)'", + "evidence": "the persisted re-read + the suppressed-error log line" + }, + { + "clause": "a condition that cannot be evaluated FAILS CLOSED (#4775): the write is ABORTED, not silently skipped — and it is NOT softened by onError:'log' nor fire-and-forgotten, because the condition gate runs OUTSIDE the async/retry/onError wrappers", + "oracle": "api", + "verify": "scratch hook with a broken/uncompilable condition on an object; a write to that object returns a HookConditionError-class refusal and the row does not land (hook-wrappers.ts: conditionFn throws outside runWithErrorPolicy)", + "evidence": "the refusal + a post-attempt count showing no row" + }, + { + "clause": "two hooks on the SAME object+event run lowest-priority-first: scratch hooks at priority 10 and 90 log in that order on one write (hook-binder passes priority to engine.registerHook; the engine orders by it, lower first)", + "oracle": "log", + "verify": "the log shows the priority-10 hook's line before the priority-90 hook's line for a single triggering write", + "evidence": "the two ordered log lines" + } + ], + "negative": [ + "a beforeInsert (or beforeDelete) hook that THROWS under onError:'abort' but leaves the row written (filtered count > 0) is a FAIL — abort must roll the WHOLE write back (the hef_ref_guard dogfood pin is the reference shape)", + "an async onError:'log' hook failure that ROLLS BACK the triggering write is a FAIL — fire-and-forget runs after the write resolved and can only be logged, never un-commit it", + "a SILENT no-fire on a real done:false→true transition is a FAIL — the audit MUST emit; if it truly did not, first rule out that the hooks registered (meta/types/hook) and that the async line was given time to settle before filing (seed-data-thin / async-settle)", + "the audit firing on a NON-transition edit of an already-done task is a FAIL — it would prove the condition collapsed from the two-root transition to a bare `record.done == true` state test (the exact #4784 regression)", + "an unevaluable condition that SILENTLY SKIPS (letting a before* guard through, or dropping an after* audit) instead of aborting the operation is a FAIL — #4775 fails closed, loudly, and onError never sees the condition error" + ], + "traps": [ + "dispatcher-vs-hono-route", + "seed-data-thin", + "stale-dist", + "wrong-persona" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts (pins the beforeInsert stamp: status='new'/source='web' on an anonymous inquiry submit); packages/qa/dogfood/test/hook-error-format.dogfood.test.ts (pins the beforeDelete onError:'abort' throw → REST error body). Declarative-wrapper semantics (two-root condition, async, retry, onError, fail-closed) are unit-pinned in packages/objectql/src/hook-wrappers.ts + hook-binder.ts tests. The transition audit / over-budget warn / priority-ordering LOG oracles and the abort/log/priority SCRATCH variants are NOT yet dogfood-pinned — drive them by hand." + }, + "source": [ + "examples/app-showcase/src/data/hooks/index.ts (the four fixture hooks + allHooks export; header comments spell out the two-root #4784 transition and the != null / not has() #4770 rationale verbatim)", + "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/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)", + "packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts + hook-error-format.dogfood.test.ts (the two existing dogfood pins this item cites)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — the hook coverage.json waiver was STALE (showcase ships 4 observable hooks); authored a lifecycle+condition+async+onError+priority item", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.field-type-constraints", + "title": "Per-type CONSTRAINT enforcement on the write path (not just widget render): length/range/option/reference/computed limits, and where the platform deliberately does NOT enforce", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_field_zoo (examples/app-showcase/src/data/objects/field-zoo.object.ts): name text maxLength 200; f_number min 0 max 1000; f_currency scale 2 currencyConfig{precision 2} min 0; f_percent min 0 max 100; f_select {low,medium,high}; f_multiselect {red,green,blue}; f_lookup→showcase_account; f_master_detail→showcase_project; f_tree→showcase_category; f_autonumber (default counter, NO autonumberFormat); f_formula = f_number*f_percent/100", + "showcase_invoice (invoice.object.ts): status select {draft,sent,paid,void} required; account lookup with lookupFilters status!=churned; total summary(sum showcase_invoice_line.amount) — the summary carrier the zoo lacks" + ], + "knownGaps": [ + "precision/scale is DECLARED but NOT enforced on the write path — record-validator.ts validateOne's number branch checks only min/max/finite, no runtime reads field.scale/precision for rounding (grep clean across objectql/rest/runtime), and the SQL driver stores number/currency/percent as table.float (REAL affinity), not DECIMAL(p,s) (sql-driver.ts createColumn ~7354-7368). A currency value with more decimals than `scale` is stored verbatim, unrounded. Record it as a gap; do NOT tick 'scale enforced'.", + "autonumberFormat TOKEN expansion ({0000}/{YYYY}/{MM}/{DD}/{YYYYMMDD}/{field}, per-rendered-prefix scope+reset) has NO showcase fixture — no showcase object declares autonumberFormat (f_autonumber uses the default bare 4-width global counter). Token expansion is pinned instead by packages/spec/src/data/autonumber-format.test.ts + packages/drivers/driver-sql/src/sql-driver-autonumber.test.ts; only server-assignment + client-value-ignored is driveable on the zoo.", + "the #4441 reference gate is EXISTENCE-only and deliberately unscoped (engine.ts assertReferencesResolve) — an id that exists but lies outside the field's lookupFilters/dependsOn scope is accepted; scope enforcement is client-only (drilled in records-forms.cascading-multilevel-and-clear).", + "summary is a runtime-MAINTAINED cache, not a write-rejected readonly field — a caller-supplied initial total is a SUPPORTED import path and is kept, then self-heals to the child aggregate on the next child write (rule-validator.ts RUNTIME_OWNED_FIELD_TYPES note, #6014). Only `formula` is strictly read-only-on-write; do NOT assert 'summary rejects direct writes'." + ] + }, + "variants": [ + "text", + "textarea", + "email", + "url", + "phone", + "number", + "currency", + "percent", + "date", + "datetime", + "time", + "select", + "radio", + "multiselect", + "checkboxes", + "tags", + "lookup", + "master_detail", + "tree", + "autonumber", + "formula", + "summary" + ], + "steps": [ + "boot showcase isolated; sign in as the seeded admin; capture an API token for direct /api/v1/data/* POST/PATCH/GET", + "STRING length: POST /api/v1/data/showcase_field_zoo with name = a 201-char string (maxLength 200) and capture the refusal; then POST a 200-char name and capture success — confirm the long value was NOT silently truncated-and-stored", + "NUMBER range: POST f_number = -1, then = 1001, then a non-number ('abc'); capture each refusal; POST f_number = 500 and re-read verbatim", + "SCALE (gap probe): POST f_currency = 1234.567 (declared scale 2); GET the row and record whether the stored value is rounded (expected: stored 1234.567 verbatim — NOT enforced)", + "SELECT server boundary: POST f_select = 'not-a-value' AND (second carrier) POST showcase_invoice status = 'archived'; capture both invalid_option refusals with their options[] echo; confirm neither row count grew", + "MULTISELECT shape: POST f_multiselect = 'red' (a scalar) and re-read as ['red'] (coerced to array); POST f_multiselect = {x:1} (a non-array object) → capture invalid_type; POST f_multiselect = ['red','purple'] → capture the per-element invalid_option naming 'purple'; POST ['red','blue'] → re-read as a set", + "REFERENCE: POST f_lookup = a fabricated account id → capture reference_not_found; POST f_lookup = a real seeded account id → re-read verbatim (note: an EXISTING but out-of-scope id is accepted — see cascading-multilevel-and-clear)", + "AUTONUMBER server-assigned: POST a row with f_autonumber = 'HACK-9999'; GET it and confirm the stored value is the engine's sequence number (client value dropped, #5503); PATCH f_autonumber on an existing row and confirm the change is dropped while the call returns success", + "FORMULA read-only: POST a row writing f_formula = 999 directly; GET and confirm f_formula equals f_number*f_percent/100 (the write was ignored); with f_number=42, f_percent=75 the read is 31.5", + "SUMMARY (nuance): create a showcase_invoice + lines; GET total and confirm it equals the line sum server-side; separately confirm a caller-supplied initial total on import is retained (maintained cache, not rejected) — do not treat summary as write-rejected" + ], + "acceptance": [ + { + "clause": "text/textarea/email/url/phone length is a REJECT, not a truncation: a value longer than the field's maxLength returns 400 VALIDATION_FAILED with fields[] {field, code:'max_length', constraint:{maxLength, actual}} and stores NOTHING — the over-length value is never silently truncated to fit", + "oracle": "api", + "verify": "the 201-char name POST returns max_length naming maxLength 200 & actual 201; the row count does not grow; a 200-char name succeeds. Codes are the ADR-0114 FieldErrorCode catalog (packages/spec/src/api/errors.zod.ts); enforcement in packages/objectql/src/validation/record-validator.ts validateOne string branch", + "evidence": "both refusal/success responses + before/after row counts" + }, + { + "clause": "number/currency/percent range is bounded both sides server-side: below `min` → code 'min_value' {min}; above `max` → 'max_value' {max}; a non-finite value → 'invalid_number'; an in-range value round-trips verbatim", + "oracle": "api", + "verify": "f_number (min 0 max 1000) rejects -1 (min_value) and 1001 (max_value) and 'abc' (invalid_number); 500 re-reads as 500. record-validator.ts validateOne number branch", + "evidence": "the three refusals + the in-range re-read" + }, + { + "clause": "KNOWN GAP recorded, not ticked: `scale`/`precision` are NOT enforced on write — a currency value with more decimals than the declared scale is stored VERBATIM (float column, validator checks only min/max/finite). The run records the observed unrounded value as a gap and must NOT report 'scale enforced'", + "oracle": "api", + "verify": "POST f_currency 1234.567 (scale 2) succeeds and GET returns 1234.567 unrounded — corroborated by record-validator.ts (no scale rounding) and sql-driver.ts createColumn (table.float, not DECIMAL). A run that shows rounding-to-2 would be a NEW enforcement to file, not a pass here", + "evidence": "the write response + the unrounded re-read" + }, + { + "clause": "closed-set types reject a non-declared option value SERVER-side on at least two carriers (not just the widget): f_select and showcase_invoice.status each refuse an out-of-set value with code 'invalid_option' and an options[] listing the allowed set; the row count does not grow", + "oracle": "api", + "verify": "direct POSTs f_select='not-a-value' and status='archived' both return invalid_option carrying options[]; record-validator.ts select/radio branch (allowed = optionValues(def.options))", + "evidence": "both refusal envelopes (with options[]) + before/after counts" + }, + { + "clause": "multiselect storage/read shape is an ARRAY: a lone scalar is coerced to a 1-element array (normalizeMultiValueFields), a non-array object is rejected 'invalid_type', an element outside options is rejected 'invalid_option' echoing the offending element, and a valid array round-trips compared as a set", + "oracle": "api", + "verify": "f_multiselect: 'red'→['red']; {x:1}→invalid_type; ['red','purple']→invalid_option naming 'purple'; ['red','blue']→set-equal on re-read. record-validator.ts multi-value branch + normalizeMultiValueFields", + "evidence": "the four responses + re-reads" + }, + { + "clause": "reference types (lookup/master_detail/tree/user) store the id verbatim and reject a DANGLING id with code 'reference_not_found' {target} for a non-system caller (#4441) — with the boundary that the gate is EXISTENCE-only: an id that exists but is out of lookupFilters/dependsOn scope is accepted (unscoped by design)", + "oracle": "api", + "verify": "a fabricated f_lookup id → reference_not_found; a real seeded account id → verbatim re-read; engine.ts assertReferencesResolve + referenceExists (isSystem/readonly/caller-supplied narrowing). Pinned by packages/objectql/src/engine-lookup-referential-integrity.test.ts", + "evidence": "the dangling refusal + the verbatim re-read + a note that an out-of-scope existing id is accepted" + }, + { + "clause": "autonumber is server-assigned and a client value is IGNORED on both write paths: a POSTed f_autonumber is replaced by the engine sequence value (stripRuntimeOwnedFields, #5503) and a PATCH of it is dropped — the call returns success while the column holds the generated number, never the forged one", + "oracle": "api", + "verify": "POST f_autonumber='HACK-9999' re-reads as a sequence number, not 'HACK-9999'; PATCH is a no-op on the field. rule-validator.ts RUNTIME_OWNED_FIELD_TYPES=['autonumber'] + stripRuntimeOwnedFields/stripReadonlyFields. Pinned by packages/objectql/src/engine-autonumber-runtime-owned.test.ts", + "evidence": "the create re-read + the PATCH no-op re-read" + }, + { + "clause": "formula is read-only/computed: a direct write to f_formula is ignored and the read equals f_number*f_percent/100 (42*75/100 = 31.5); summary is a runtime-MAINTAINED cache (invoice.total = Σ line.amount server-side) and is NOT write-rejected — a caller's initial total is a supported import path that self-heals on the next child write", + "oracle": "api", + "verify": "GET f_formula after a direct write shows 31.5 (write ignored); GET showcase_invoice.total equals the seeded line sum (objectui e2e/live/summary-rollup.spec.ts). The formula/summary distinction: rule-validator.ts note (formula computed-on-read; summary maintained cache, deliberately NOT in RUNTIME_OWNED)", + "evidence": "the formula re-read + the invoice total re-read" + } + ], + "negative": [ + "a 201-char value silently TRUNCATED to 200 and stored with 200/201 is a FAIL — the contract is reject-not-truncate (max_length), and a truncating write corrupts the value with no signal", + "an out-of-set select/status value accepted with 200 is a FAIL — the record-validator, not the picker, is the boundary", + "a client-supplied f_autonumber persisted verbatim is a FAIL — a forged business identifier bypassing the sequence (#5503)", + "a currency value rounded to `scale` and reported as 'scale enforced' is a FALSE PASS — the platform does not round on write; the honest verdict is the recorded gap", + "a dangling lookup id accepted with 200 is a FAIL (#4441)" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle" + ], + "automated": { + "kind": "api", + "ref": "packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts vectors, field-zoo-value-shape.test.ts) for round-trip & masking; packages/objectql/src/engine-lookup-referential-integrity.test.ts for reference_not_found (#4441); packages/objectql/src/engine-autonumber-runtime-owned.test.ts for the autonumber strip (#5503)" + }, + "source": [ + "packages/objectql/src/validation/record-validator.ts (validateOne: max_length/min_length, min_value/max_value/invalid_number, invalid_option for select+multiselect, normalizeMultiValueFields; NO scale/precision rounding)", + "packages/spec/src/api/errors.zod.ts (FieldErrorCode catalog — required, max_length, min_value/max_value, invalid_option, reference_not_found, invalid_type … ADR-0114)", + "packages/spec/src/data/field.zod.ts (per-type constraints: maxLength/minLength, precision/scale/min/max, options value-vs-label, lookupFilters/dependsOn, autonumberFormat tokens, formula expression, summaryOperations)", + "packages/drivers/driver-sql/src/sql-driver.ts (createColumn: number/currency/percent → table.float, NOT DECIMAL; datetime → DATETIME(3)/timestamptz)", + "packages/objectql/src/engine.ts (assertReferencesResolve #4441 — existence-only, unscoped)", + "packages/objectql/src/validation/rule-validator.ts (RUNTIME_OWNED_FIELD_TYPES=['autonumber']; stripRuntimeOwnedFields/stripReadonlyFields; formula-vs-summary note #6014)", + "packages/spec/src/data/autonumber-format.ts (token grammar) + autonumber-format.test.ts", + "examples/app-showcase/src/data/objects/field-zoo.object.ts, invoice.object.ts (status/account/total carriers)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — drills the field-type-matrix's shallow 'reject invalid' clause into per-type CONSTRAINT enforcement on the write path (length reject-not-truncate, numeric range, option server-boundary on two carriers, multiselect array shape, dangling-reference gate + its unscoped boundary, autonumber server-assignment, formula read-only) and records the deliberate non-enforcement gaps (scale/precision, autonumberFormat tokens, summary maintained-not-rejected)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.cascading-multilevel-and-clear", + "title": "Cascade clear-semantics and the server boundary: stale-child clear on parent change, the WRITTEN-value server gate, and where cascade scope is client-only", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_cascade (examples/app-showcase/src/data/objects/cascading-select.object.ts): country → province, province dependsOn ['country'] with per-option visibleWhen (cn→zj/gd, us→ca/tx); sharingModel public_read_write", + "showcase_invoice.contact dependsOn ['account'] (invoice.object.ts) — the dependent LOOKUP twin; account carries lookupFilters status!=churned; seed spread Northwind/Contoso/Fabrikam (examples/app-showcase/src/data/seed/index.ts)" + ], + "knownGaps": [ + "NO 3-level (grandparent→parent→child) dependsOn fixture exists in the showcase — the deepest chain is 2-level (country→province). The 'changing the grandparent clears/revalidates BOTH descendants' behavior cannot be driven; blocked on a fixture (a 3-level cascade object). The 2-level clear (clause 0) and the server WRITTEN-value gate (clause 1) are the driveable depth.", + "SELECT cascade server enforcement is WRITTEN-value-only: evaluateOptionVisibility re-checks a picked option only when that field is IN the write payload (rule-validator.ts: `!(name in data) continue`). A PATCH that changes ONLY the parent and omits the now-invalid child does NOT revalidate it server-side (clause 2) — server-side cascade integrity on the change-parent path depends entirely on the CLIENT clear. Recorded as a product gap.", + "LOOKUP cascade scope is NOT server-enforced at all: the #4441 reference gate (engine.ts assertReferencesResolve) is deliberately EXISTENCE-only/unscoped, so a directly-POSTed contact that belongs to a DIFFERENT account is accepted as long as the row exists (clause 4). dependsOn/lookupFilters is a picker-side narrowing; the only server reference guard is dangling→reference_not_found." + ] + }, + "steps": [ + "boot showcase isolated; sign in as the seeded admin; keep an API token for direct /api/v1/data/* writes", + "SELECT client clear: open a New Cascading Select (/_console/apps/com.example.showcase/showcase_cascade); pick country=cn, province=zj; screenshot; switch country=us; screenshot; enumerate province options (expect {California,Texas}) and confirm zj CLEARED and not carried into the us submit", + "SELECT server gate (written value): direct POST /api/v1/data/showcase_cascade {country:'cn', province:'ca'} → capture the refusal; then {country:'cn', province:'zj'} → capture success", + "SELECT stale-child EDGE (gap probe): create {country:'cn', province:'zj'}; PATCH only {country:'us'} (omit province); GET the row and record whether province='zj' (now invalid for us) is still stored and the PATCH returned 200 — expected: accepted, stale value kept (server does not revalidate an unwritten child)", + "LOOKUP twin client re-query + clear: on a New Invoice, choose account=Northwind and capture the contact picker's candidate request (scoped to Northwind); pick a Northwind contact; switch account=Contoso; capture the re-issued (Contoso-scoped) candidate request and confirm the Northwind contact CLEARED from the field", + "LOOKUP scope server boundary (gap probe): direct POST /api/v1/data/showcase_invoice with account=<Contoso id> and contact=<a Northwind contact id that exists>; GET and record whether the cross-account contact was accepted (expected: 200, existence-only gate) vs a fabricated contact id (expected: reference_not_found)", + "persistence: create country=cn province=zj (and, for the twin, an invoice with account=Northwind contact=<Northwind contact>) and re-read both verbatim after reload — no orphaned/invalid child" + ], + "acceptance": [ + { + "clause": "SELECT client clear-on-change: changing country re-filters province to the new country's set AND clears a now-invalid prior selection — the stale province is not carried into the submit", + "oracle": "dom", + "verify": "after screenshots confirm render, enumerate province options at cn (zj,gd) then us (ca,tx) and confirm zj cleared; pinned by objectui e2e/live/cascading-options.spec.ts ('province options re-filter live as country changes, and the stale value clears')", + "evidence": "before/after screenshots + option enumerations + the cleared field state" + }, + { + "clause": "SELECT server WRITTEN-value gate: a directly-POSTed child inconsistent with its parent (both in the payload) is rejected with code 'invalid_option' naming province; the consistent one is accepted — the objectql rule-validator, not the picker, is the boundary", + "oracle": "api", + "verify": "{country:'cn',province:'ca'} → invalid_option (message key option_unavailable) on province; {country:'cn',province:'zj'} → 200. rule-validator.ts evaluateOptionVisibility re-evaluates the picked option's visibleWhen over the merged record. Pinned by packages/objectql/src/validation/rule-validator.option-visibility.test.ts", + "evidence": "both responses" + }, + { + "clause": "KNOWN GAP recorded: a parent-only PATCH does NOT revalidate an unwritten stale child — after create {cn,zj} a PATCH {country:'us'} (province omitted) is accepted 200 and the row still holds province='zj' (invalid for us). The server checks only WRITTEN choice fields; the client clear is the only thing that keeps the change-parent path consistent", + "oracle": "api", + "verify": "the create → parent-only PATCH → GET sequence shows province='zj' retained under country='us'; grounded in rule-validator.ts evaluateOptionVisibility `!(name in data) continue`. A run that shows the server auto-clearing/rejecting the stale child would be NEW enforcement to file, not a pass here", + "evidence": "the create, the parent-only PATCH response, and the GET showing the retained stale child" + }, + { + "clause": "LOOKUP twin re-queries and clears: the invoice contact picker issues account-scoped candidate requests, switching account re-issues a request scoped to the new account, AND a previously-chosen contact from the old account is cleared from the field", + "oracle": "network", + "verify": "captured picker requests carry the account scope and the candidate set changes with the account (counts track the seed spread); the field value clears on the account switch (screenshot+dom corroboration). invoice.object.ts contact dependsOn ['account']", + "evidence": "both picker request traces + counts + the cleared-field screenshot" + }, + { + "clause": "KNOWN GAP recorded: LOOKUP cascade scope is NOT server-enforced — a directly-POSTed contact that belongs to a DIFFERENT account but EXISTS is accepted (200), while only a nonexistent contact id is refused with 'reference_not_found'. dependsOn/lookupFilters narrows the picker for UX only; the server reference gate is existence-only by design", + "oracle": "api", + "verify": "POST invoice {account:<Contoso>, contact:<existing Northwind contact>} → 200; POST with a fabricated contact id → reference_not_found. engine.ts assertReferencesResolve is deliberately unscoped (its own 'Why the probe is unscoped' note). A run that shows the cross-account contact rejected would be NEW scope enforcement to file", + "evidence": "the cross-account 200 + the fabricated-id reference_not_found" + }, + { + "clause": "a legal cascade selection persists consistently: create country=cn province=zj (and account=Northwind contact=<Northwind contact> on the twin) re-reads verbatim after reload — no orphaned or invalid child value survives the round-trip", + "oracle": "api", + "verify": "GET both created rows; the cascade pairs are present and mutually consistent", + "evidence": "the two re-reads" + } + ], + "negative": [ + "a stale invalid child value that IS in the write payload accepted with 200 is a FAIL — the WRITTEN-value gate (evaluateOptionVisibility) must reject it (client hiding is UX, not the boundary)", + "an out-of-set / parent-inconsistent province accepted with 200 (child in payload) is a FAIL", + "ticking 'the server enforces the cascade' on the strength of the SELECT written-value gate is a FALSE PASS for the two recorded gaps: the parent-only PATCH (stale child kept) and the LOOKUP scope (cross-account contact accepted) are BOTH client-only today" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle", + "wrong-persona" + ], + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/cascading-options.spec.ts (client clear-on-change); packages/objectql/src/validation/rule-validator.option-visibility.test.ts (server WRITTEN-value gate)" + }, + "source": [ + "packages/objectql/src/validation/rule-validator.ts (evaluateOptionVisibility — WRITTEN-value only via `!(name in data) continue`; fail-open on unevaluable; invalid_option/option_unavailable)", + "packages/objectql/src/engine.ts (assertReferencesResolve #4441 — existence-only, 'Why the probe is unscoped')", + "examples/app-showcase/src/data/objects/cascading-select.object.ts (2-level country→province; no 3rd level)", + "examples/app-showcase/src/data/objects/invoice.object.ts (contact dependsOn ['account'], account lookupFilters)", + "examples/app-showcase/src/data/seed/index.ts (contact spread per account)", + "objectui: e2e/live/cascading-options.spec.ts" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — drills the cascade EDGES cascading-options asserts shallowly: stale-child clear-on-change (client), the WRITTEN-value server gate, and the two recorded product gaps (parent-only PATCH does not revalidate an unwritten child; LOOKUP dependsOn scope is existence-only/unscoped server-side). 3-level chain recorded as a fixture gap (no showcase fixture deeper than 2 levels)", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + }, + { + "id": "records-forms.encrypted-field-behavior", + "title": "Secret/encrypted field: ciphertext at rest, masked on every read, no-op mask re-submit, and fail-CLOSED without an ICryptoProvider (ADR-0100)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai / admin123)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_field_zoo.f_secret = Field.secret (encrypted-at-rest) and f_password = Field.password({ackPlaintextMasking:true}) (examples/app-showcase/src/data/objects/field-zoo.object.ts)", + "a CryptoProvider-wired boot: `os serve` wires LocalCryptoProvider (AES-256-GCM, OS_SECRET_KEY or a persisted dev key) via engine.setCryptoProvider when the data engine + service-settings resolve (packages/cli/src/commands/serve.ts) — so on a stock dev boot f_secret writes SUCCEED (ciphertext); sys_secret platform object must be registered" + ], + "knownGaps": [ + "f_secret is NOT seeded (the Specimen rows omit it) — drive a live UI/API write to exercise the encrypt path.", + "the fail-CLOSED path is not observed on a stock `os serve` boot (which wires LocalCryptoProvider by default) — it is observed by deliberately unwiring the provider (no OS_SECRET_KEY under the production guard, or a boot without service-settings), or via the unit pin (secret-fields.test.ts + engine.ts encryptSecretFields throw). The field-type-matrix's 'seed deliberately omits it' note is about SEEDING, not serve-time wiring.", + "inspecting the raw business-row column / sys_secret ciphertext needs DB access (file:/tmp/<run>/data.db) — where the API is the only surface, assert the mask on read + the absence of plaintext in any API response instead." + ] + }, + "steps": [ + "boot showcase isolated on a provider-wired boot (`os serve`, LocalCryptoProvider); sign in as the seeded admin; keep an API token", + "WRITE ciphertext: POST /api/v1/data/showcase_field_zoo {name:'sec-1', f_secret:'topsecret-value'}; GET the row over the API and confirm f_secret reads back as the SECRET_MASK, never 'topsecret-value'; where DB access exists, confirm the business-row column holds a 'secret:<handle>' ref and the plaintext lives only (encrypted) in sys_secret", + "MASK on read for f_password too: POST f_password='p@ssw0rd!'; GET and confirm f_password reads back as the mask (generic password: plaintext-at-rest but masked-on-read)", + "ECHOED-MASK no-op: PATCH the row's f_secret back as the SECRET_MASK sentinel; GET and confirm the stored secret is UNCHANGED (a form round-trip re-submitting the mask does not wipe it)", + "FAIL-CLOSED: on a boot with NO CryptoProvider registered (deliberately unwired), POST f_secret='x' and capture the refusal; confirm NOTHING was persisted (no row, or the field left unset) — cleartext never lands", + "AGGREGATE guard: issue an analytics/group-by request that MIN/MAX/GROUP BYs f_secret (or f_password) and capture the rejection (a credential column is not aggregable — inference-oracle guard)", + "cross-check the negatives: scan every API response captured above and confirm no plaintext 'topsecret-value' / 'p@ssw0rd!' appears anywhere" + ], + "acceptance": [ + { + "clause": "secret WRITE stores ciphertext, never plaintext: after POSTing f_secret, the business row holds an opaque 'secret:<handle>' ref (SECRET_REF_PREFIX) — the plaintext is encrypted into sys_secret via the ICryptoProvider — and no API read echoes the written value", + "oracle": "api", + "verify": "GET returns the mask, not 'topsecret-value'; where DB-inspectable, the column value starts with 'secret:' and sys_secret holds the ciphertext. engine.ts encryptSecretFields (cryptoProvider.encrypt → sys_secret → makeSecretRef); packages/objectql/src/secret-fields.ts", + "evidence": "the write response + the masked GET + (where available) the raw column / sys_secret excerpt" + }, + { + "clause": "reads MASK for every reader: a GET of the record returns f_secret and f_password as SECRET_MASK '••••••••', not the written value — masking on the generic read path is unconditional (plaintext requires an explicit engine decrypt the API read never performs), so even a privileged reader gets the mask", + "oracle": "api", + "verify": "both credential fields read back masked; collectMaskedReadFields (secret always; password unless managedBy:'better-auth'). Pinned by packages/qa/dogfood/test/field-zoo.matrix.ts kind:'masked' (f_secret, f_password) + packages/objectql/src/secret-fields.test.ts", + "evidence": "the masked GET + the pin output" + }, + { + "clause": "an echoed mask is a NO-OP, not a clobber: PATCHing f_secret back as the SECRET_MASK sentinel drops the key and leaves the stored secret unchanged — a form round-trip that re-submits the mask does not wipe the secret", + "oracle": "api", + "verify": "after the mask-PATCH the secret still resolves (mask on read, decrypts to the original where checked); engine.ts encryptSecretFields echoed-mask drop (value === SECRET_MASK ⇒ delete key)", + "evidence": "the mask-PATCH response + the post-PATCH read showing the secret intact" + }, + { + "clause": "fail-CLOSED without a provider: with no ICryptoProvider registered, a non-empty f_secret write is REFUSED ('Refusing to store cleartext … fail-closed') and NOTHING is persisted — cleartext never reaches the business row; a `password` write needs no provider (plaintext at rest, masked on read)", + "oracle": "api", + "verify": "the unwired-boot POST throws the fail-closed error and the row is absent/unset; engine.ts encryptSecretFields (`if (!this.cryptoProvider) throw`). Pinned by packages/objectql/src/secret-fields.test.ts. On a stock `os serve` boot the provider IS wired, so this is a deliberately-unwired or unit-pin verdict", + "evidence": "the refusal + a GET/list confirming no persisted cleartext" + }, + { + "clause": "a credential column cannot be AGGREGATED: a MIN/MAX/GROUP BY over f_secret or f_password is rejected unconditionally (even on a better-auth object) — an inference-oracle guard keyed off collectCredentialFields (ADR-0100 / #3171)", + "oracle": "api", + "verify": "the group-by/aggregate request over the credential field returns a rejection, not a leaked value distribution; packages/objectql/src/secret-fields.ts collectCredentialFields", + "evidence": "the aggregate rejection response" + } + ], + "negative": [ + "any API read returning f_secret/f_password plaintext is a FAIL regardless of what the form shows", + "an f_secret write that persists cleartext to the business row — or succeeds at all with NO provider registered — is a FAIL (fail-open is exactly what ADR-0100 forbids)", + "a re-submitted SECRET_MASK that WIPES the stored secret is a FAIL (the echoed mask must be a no-op)", + "an aggregate over a credential column that returns a value distribution is a FAIL (inference oracle)" + ], + "traps": [ + "hydration-race", + "automation-input", + "stale-console-bundle" + ], + "automated": { + "kind": "api", + "ref": "packages/objectql/src/secret-fields.test.ts (encrypt/mask/fail-closed); packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts masked vectors for f_secret & f_password)" + }, + "source": [ + "packages/objectql/src/secret-fields.ts (SECRET_MASK, SECRET_REF_PREFIX, collectSecretFields/collectMaskedReadFields/collectCredentialFields)", + "packages/objectql/src/engine.ts (encryptSecretFields — encrypt→sys_secret→ref, echoed-mask drop, fail-closed throw; decrypt path)", + "packages/spec/src/data/field.zod.ts (FieldType 'secret'/'password' ADR-0100 notes; ackPlaintextMasking)", + "packages/cli/src/commands/serve.ts (LocalCryptoProvider host wiring via setCryptoProvider)", + "packages/platform-objects/src/system/sys-secret.object.ts (ciphertext store)", + "examples/app-showcase/src/data/objects/field-zoo.object.ts (f_secret, f_password); packages/qa/dogfood/test/field-zoo.matrix.ts (masked vectors)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-08", + "change": "initial — drills the field-type-matrix's shallow 'credential types mask on read' clause into the full ADR-0100 secret contract: ciphertext-at-rest (sys_secret ref), unconditional read mask for every reader, echoed-mask no-op, fail-CLOSED without an ICryptoProvider, and the credential-aggregate guard. Records that `os serve` wires LocalCryptoProvider by default (fail-closed observed by unwiring) and that f_secret is unseeded", + "ref": "claude/platform-test-checklist-ocwugl" + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json new file mode 100644 index 0000000000..f9afcad653 --- /dev/null +++ b/docs/qa/platform-checklist/areas/search.json @@ -0,0 +1,471 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "search", + "title": "Record search — $search executor, field scoping, RLS composition, pinyin recall, freshness", + "items": [ + { + "id": "search.cross-field-object-search", + "title": "$search is a server-resolved cross-field match: terms AND-ed, fields OR-ed, case-insensitive, select labels mapped to values", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the seeded accounts: Northwind (industry 'retail', name does NOT contain 'retail'), Acme Retail (name-hit control), Contoso (cross-term control) — examples/app-showcase/src/data/seed/index.ts" + ] + }, + "steps": [ + "POST /api/v1/data/showcase_account/query with { search: 'retail' } and record the returned names", + "verify the premise before ticking anything: Northwind's NAME must not contain 'retail' (else the multi-field claim proves nothing)", + "POST the same search narrowed with { search: 'retail', searchFields: ['industry'] } — Northwind must still return, positively pinning the hit to the industry field", + "POST { search: 'Retail' } (capitalized label) — the select label→value mapping must still match the stored 'retail' value", + "POST { search: 'retail northwind' } (terms AND across different fields of one row) and { search: 'retail contoso' } (terms that no single row satisfies)", + "capture every response body as the evidence set" + ], + "acceptance": [ + { + "clause": "a term matching only a non-name field returns the row: 'retail' returns Northwind via industry, and the same search restricted to ['industry'] still returns it", + "oracle": "api", + "verify": "Northwind present in both responses; its name verified not to contain the term (premise guard)", + "evidence": "the two response bodies" + }, + { + "clause": "fields are OR-ed within one query: the unrestricted 'retail' search also returns the name-hit control (Acme Retail) alongside the industry hit", + "oracle": "api", + "verify": "both Northwind and a name-containing-'retail' account appear in one result set", + "evidence": "the response body" + }, + { + "clause": "matching is case-insensitive and select labels map to option values: 'Retail' (label case) matches rows storing the value 'retail'", + "oracle": "api", + "verify": "the capitalized search still returns Northwind (optionValuesMatching + raw-value $contains fallback)", + "evidence": "the response body" + }, + { + "clause": "whitespace-separated terms AND: every term must hit some field of the SAME row — 'retail northwind' matches, 'retail contoso' returns neither Northwind nor Contoso", + "oracle": "api", + "verify": "the two multi-term responses split exactly that way", + "evidence": "the two response bodies" + } + ], + "negative": [ + "'retail contoso' returning Contoso (terms OR-ed instead of AND-ed) is a FAIL against the declared matching semantics", + "an empty result for 'retail' means the executor silently dropped $search — the exact pre-ADR-0061 no-op this surface replaced; FAIL, not thin data" + ], + "variants": [ + "multi-term AND", + "cross-field OR", + "case-insensitive $contains", + "select label→value mapping" + ], + "traps": ["seed-data-thin", "dispatcher-vs-hono-route"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/showcase-search.dogfood.test.ts" }, + "source": [ + "packages/qa/dogfood/test/search-conformance.ledger.ts (rows search-executor, search-select-label-mapping — the variants list is the enforced behavior set)", + "packages/objectql/src/search-filter.ts (matching semantics: terms AND-ed, fields OR-ed, case-insensitive, label mapping)", + "packages/objectql/src/engine.ts expandSearchOnAst (the executor site the ledger names)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item transcribed from the search-conformance ledger and its HTTP-level dogfood proof, seeded names (Northwind/Acme Retail/Contoso) verified in the showcase seed", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "search.field-scoped-narrowing", + "title": "$searchFields narrows and can never widen; a name outside the searchable set is 400 INVALID_FIELD at the ingress, never silently dropped", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": ["the same seeded accounts as search.cross-field-object-search"] + }, + "steps": [ + "POST /api/v1/data/showcase_account/query { search: 'retail', searchFields: ['name'] } — the industry-only hit (Northwind) must drop out", + "POST { search: 'retail', searchFields: ['industry'] } — Northwind must remain (narrowing to the right field keeps the hit)", + "POST { search: 'retail', searchFields: ['no_such_field'] } and capture the refusal", + "POST { search: 'retail', searchFields: ['annual_revenue'] } (a REAL field outside the searchable set — numbers are not searchable-textual) and capture the refusal", + "POST { search: 'zhangwei', searchFields: ['__search'] } against showcase_contact — the hidden companion column must be refusable/invisible to overrides, not a client-nameable widening lever", + "GET the OData spelling too: /api/v1/data/showcase_account?$search=retail&$searchFields=name — the protocol layer normalizes both spellings to the same executor" + ], + "acceptance": [ + { + "clause": "narrowing works: restricted to ['name'], the industry-matched row disappears; restricted to ['industry'], it stays", + "oracle": "api", + "verify": "the two narrowed responses split exactly that way", + "evidence": "both response bodies" + }, + { + "clause": "the override can only narrow — a $searchFields name the object cannot scan is 400 INVALID_FIELD at the REST ingress (#4254), for typos AND for real-but-unsearchable fields alike", + "oracle": "api", + "verify": "both bad-override requests answer 400 with code INVALID_FIELD; neither silently returns the unnarrowed superset", + "evidence": "the two refusal bodies" + }, + { + "clause": "the hidden __search companion is invisible to overrides and to responses: naming it in $searchFields is refused, and no record body ever echoes a __search value", + "oracle": "api", + "verify": "the ['__search'] override is refused; record payloads from any search carry no __search key", + "evidence": "the refusal + a sampled record body" + }, + { + "clause": "both wire spellings (bare search/searchFields in the query POST body, $search/$searchFields on GET) reach the same executor with the same verdicts", + "oracle": "api", + "verify": "the GET spelling reproduces the POST results and refusals", + "evidence": "paired responses" + } + ], + "negative": [ + "a bad $searchFields that answers 200 with unnarrowed rows is the widening leak #4254 closed — a projection typo returns extra columns, a search typo returns extra ROWS; FAIL", + "silent dropping of the override (200, results identical to the unrestricted search) is equally a FAIL — refusal must be loud" + ], + "traps": ["dispatcher-vs-hono-route"], + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/showcase-search.dogfood.test.ts" }, + "source": [ + "packages/qa/dogfood/test/search-conformance.ledger.ts (row search-fields-override: intersection + ingress gate, #4254)", + "packages/metadata-protocol/src/protocol.ts (assertSearchFieldsAreSearchable — 400 INVALID_FIELD; $-spelling normalization)", + "packages/objectql/src/search-filter.ts (resolveSearchFields intersection; companion excluded from resolution — 'invisible to $searchFields overrides and to clients')" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item from the conformance ledger's override row and the #4254 ingress-gate source, including the companion-invisibility clause from search-filter.ts", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "search.rls-both-personas", + "title": "Search honors RLS both ways: a restricted member gets no hits — and no total leakage — from rows they cannot see; the entitled persona finds the same rows", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (sees all invoices)", "signed-up member bound to the contributor position (invoice_own_rows RLS: owner == current_user.email)"], + "fixtures": { + "app": "showcase", + "requires": [ + "seeded invoices INV-1001..INV-1008 with owner emails (ada@example.com / linus@example.com / grace@example.com) none of which belong to the test member", + "a signed-up member holding the contributor position — positions are seeded, users are not: sign the member up, then bind contributor (sys_user_position) before the run" + ], + "knownGaps": [ + "the seeded owner emails are metadata-only (users cannot be seeded; they sign up) — no stock login exists for ada@example.com, so the 'restricted member owns SOME rows' side is produced by having the member CREATE an invoice of their own (contributors hold allowCreate), not by borrowing a seeded owner" + ] + }, + "steps": [ + "premise guard first: as the contributor-bound member, GET /api/v1/data/showcase_invoice/<id-of-INV-1003> — it must be invisible (else the search verdict proves nothing)", + "as the member, POST /api/v1/data/showcase_invoice/query { search: 'INV-1003' } and record records + total", + "as admin, run the identical search — INV-1003 must return (the entitled side of the same gate)", + "as the member, create their own invoice (POST /api/v1/data/showcase_invoice, name 'INV-QA-RLS') and search for it — their own rows must remain findable", + "as the member, run a broad paged search { search: 'INV', limit: 2 } and record records/total/hasMore across pages", + "compare the member's paged totals against their full visible row set" + ], + "acceptance": [ + { + "clause": "the restricted member's search returns zero hits for rows their RLS hides, while the identical admin search returns them — both sides of the gate, same query", + "oracle": "api", + "verify": "member: records []; admin: INV-1003 present; the by-id premise guard confirmed invisibility first", + "evidence": "the paired responses + premise read" + }, + { + "clause": "no count leakage: a searched list's total/hasMore derive from the caller-scoped result set — with `search` present the protocol computes a page-local total from the RLS-filtered find (it never runs a raw count for searched lists), and engine.count itself rides the same read middleware (#2737)", + "oracle": "api", + "verify": "the member's paged totals reconcile exactly with the rows they can enumerate; no response reveals the true 8-row population", + "evidence": "the paged responses + reconciliation table" + }, + { + "clause": "the restriction is subtractive, not a blackout: the member still finds rows they own ('INV-QA-RLS' returns for its creator)", + "oracle": "api", + "verify": "the member's own-row search hits", + "evidence": "the response" + }, + { + "clause": "search rides the engine read path, so the RLS composition is structural: $search only ANDs a filter into ast.where before the security middlewares scope it — verified black-box by the persona split above, not assumed", + "oracle": "api", + "verify": "the persona-split evidence set is complete (deny + allow + own-rows); cite expandSearchOnAst + the middleware ordering as the mechanism, the responses as the proof", + "evidence": "the full evidence set" + } + ], + "negative": [ + "any hit (or any total inflation) for the restricted member from an invisible row is an RLS bypass — P1 FAIL, reproduce twice and file", + "running the deny side as admin proves nothing (wrong-persona trap): the guard check must run as the non-privileged member" + ], + "traps": ["wrong-persona", "seed-data-thin"], + "source": [ + "examples/app-showcase/src/security/permission-sets.ts (invoice_own_rows: owner == current_user.email, positions ['contributor'])", + "examples/app-showcase/src/data/seed/index.ts (INV-1001..1008 owner spread; 'ada sees INV-1001/1002's lines but never linus's INV-1003')", + "packages/objectql/src/engine.ts (expandSearchOnAst ANDs into ast.where; count() rides the read middleware — the #2737 total-leak fix)", + "packages/metadata-protocol/src/protocol.ts (searched lists: countable = search == null → page-local total from the scoped find)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: RLS × $search both-personas contract; total-leakage clause grounded in the protocol's page-local total for searched lists and the #2737 count middleware fix, persona provisioning gap recorded honestly", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "search.pinyin-flag-both-sides", + "title": "Pinyin recall is gated end-to-end by OS_SEARCH_PINYIN_ENABLED: on, latin pinyin (full + initials) hits CJK names; off, it does not — while CJK terms keep matching", + "since": "v15.1", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": [ + "seeded CJK rows: contact 张伟 (zhangwei@huaning.example) and account 华宁科技 — placed in the seed precisely so pinyin recall is demonstrable out of the box", + "the showcase i18n config (supportedLocales includes zh-CN), which makes the flag default ON: unset OS_SEARCH_PINYIN_ENABLED + any zh-* locale → enabled, stamped into the env at serve boot", + "an environment lever for the OFF side: restart the server with OS_SEARCH_PINYIN_ENABLED=false (an explicit value always beats the locale-derived default — truthy is exactly 1/true/on/yes; anything else explicit disables)" + ] + }, + "steps": [ + "on the stock boot (flag auto-ON via zh-CN locale), POST /api/v1/data/showcase_contact/query { search: 'zhangwei' } — full pinyin must return 张伟", + "POST { search: 'zw' } — initials must return 张伟", + "POST { search: '张' } — the CJK original must return 张伟 (source-column hit, no companion involved)", + "POST /api/v1/data/showcase_account/query { search: 'huaning' } and { search: 'hnkj' } — both must return 华宁科技", + "confirm the seeded rows were companion-backfilled on this very boot (rows written by seeds BEFORE hook binding are reconciled by the kernel:bootstrapped backfill — no restart needed) by checking the boot log for the backfill, then verify in the browser that the list quick-search / ⌘K / lookup picker send the same $search and hit", + "restart the server with OS_SEARCH_PINYIN_ENABLED=false and re-run the same queries", + "capture the boot log line proving the plugin went inert" + ], + "acceptance": [ + { + "clause": "flag ON: every latin recall variant hits — full pinyin (zhangwei → 张伟), initials (zw → 张伟), and the account forms (huaning / hnkj → 华宁科技) — each verified per-variant against the API", + "oracle": "api", + "verify": "one query per variant, each returning the seeded CJK row", + "evidence": "per-variant response bodies" + }, + { + "clause": "the recall is additive and scoped to the display/name field: only the resolved display field feeds the companion; other fields are searched via their source columns directly", + "oracle": "api", + "verify": "a latin term matching a NON-name CJK field does not gain pinyin recall (e.g. company 华宁科技 on a contact whose name is latin) — matches come only where the source column or the name-fed companion matches", + "evidence": "the contrast query" + }, + { + "clause": "flag OFF (explicit false, restart): 'zhangwei'/'zw' no longer return 张伟 — the companion is gone from the schema view and the filter never ORs it — while '张' still matches via the source column", + "oracle": "api", + "verify": "the same three queries after the flagged restart split exactly that way, with no errors", + "evidence": "post-restart response bodies" + }, + { + "clause": "OFF is inert end-to-end, not half-disabled: the plugin logs 'OS_SEARCH_PINYIN_ENABLED is off — inert' (no hooks, no backfill, pinyin-pro never imported) — the ADR-0049 no-half-state design: column provisioning and population share the SINGLE decision point resolveSearchPinyinEnabled", + "oracle": "log", + "verify": "the boot log carries the inert line and no backfill activity", + "evidence": "boot log excerpt" + }, + { + "clause": "seeded (pre-hook) rows are recallable on the FIRST boot: the kernel:bootstrapped backfill reconciles rows written before/around hook binding, and existing deployments' stale rows after a restart (#3027 / 15.1 plan G9)", + "oracle": "api", + "verify": "the flag-ON queries above succeed on a fresh boot without any manual rebuild; the backfill ran per the boot log", + "evidence": "fresh-boot query results + log" + } + ], + "negative": [ + "with the flag off, a 500 or error on latin searches is a FAIL — disabling must degrade to no-recall, not to breakage", + "with the flag on, 'zw' matching NOTHING while 'zhangwei' matches means the initials normalizer regressed — per-variant verification exists precisely so one form cannot stand in for the other" + ], + "variants": [ + "full pinyin (zhangwei → 张伟)", + "initials (zw → 张伟)", + "CJK original (张 → 张伟; flag-independent)", + "account full pinyin (huaning → 华宁科技)", + "account initials (hnkj → 华宁科技)", + "flag OFF contrast (latin forms miss, CJK still hits)" + ], + "traps": ["stale-dist", "seed-data-thin"], + "source": [ + "packages/types/src/env.ts (resolveSearchPinyinEnabled — explicit env wins, truthy set {1,true,on,yes}; unset derives from zh-* locales; stampSearchPinyinEnabled at boot)", + "packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts (inert-when-off, before-save hooks, kernel:bootstrapped backfill)", + "packages/objectql/src/search-companion.ts (__search companion: display-field-only materialization, FLS/secret eligibility gate)", + "packages/objectql/src/search-filter.ts (each latin term ORs { __search: { $contains: term } } — purely additive)", + "examples/app-showcase/src/data/seed/index.ts (张伟/华宁科技 seeded for exactly this demo) + examples/app-showcase/objectstack.config.ts (supportedLocales ['en','zh-CN'])", + "docs/plans/release-15.1-test-plan.md E5/G9 (#3027/#3034)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: both sides of the pinyin flag with toggle semantics read from resolveSearchPinyinEnabled's source (explicit-wins + zh-locale default), recall variants pinned to the seeded CJK rows, backfill contract from the plugin source", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "search.freshness-and-empty", + "title": "Search reflects create/update/delete immediately (query-time expansion, no async index) and a no-hit search degrades to a clean empty result, never an error", + "since": "v15", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["seeded admin (admin@objectos.ai)"], + "fixtures": { + "app": "showcase", + "requires": ["write access to showcase_account (admin) for the probe records"] + }, + "steps": [ + "POST /api/v1/data/showcase_account { name: 'Zephyr Search Probe', status: 'prospect' } and IMMEDIATELY POST /api/v1/data/showcase_account/query { search: 'zephyr' }", + "rename the record to 'Quasar Search Probe' (PATCH) and immediately search 'quasar' AND 'zephyr'", + "create a CJK probe contact (name '搜索测试', any email) and immediately search its full pinyin ('sousuoceshi') — the companion recomputes in the SAME save (before-save hook), so no restart may be needed", + "delete the probe records and immediately re-run their searches", + "POST { search: 'qqqzzz-no-such-term' } and record the full response envelope", + "in the browser, type the same no-hit term into the Accounts list quick-search; screenshot the result and capture the console" + ], + "acceptance": [ + { + "clause": "a just-created record is findable in the immediately-following search — Tier-1 $search is a query-time WHERE expansion over the live table (no separate index, no async pipeline), so API-path writes are read-your-writes", + "oracle": "api", + "verify": "the create→search sequence hits with zero wait/retry", + "evidence": "the timestamped request pair" + }, + { + "clause": "an update is reflected immediately and completely: the new term matches, the OLD term stops matching", + "oracle": "api", + "verify": "post-rename, 'quasar' hits and 'zephyr' misses", + "evidence": "both responses" + }, + { + "clause": "a deleted record stops being findable immediately", + "oracle": "api", + "verify": "post-delete searches return no probe rows", + "evidence": "the responses" + }, + { + "clause": "pinyin freshness rides the same write: the __search companion is recomputed by before-save hooks in the SAME save when the display field changes — a freshly created CJK row is pinyin-findable without any restart. The ONLY deferred path is hook-bypassing writes (direct driver/system writes, rows predating the flag), reconciled at the next boot's kernel:bootstrapped backfill or an explicit rebuildSearchCompanion — that is the actual consistency contract, not instant-for-everything", + "oracle": "api", + "verify": "the fresh CJK probe hits by full pinyin immediately; the deferred-path caveat is recorded in the evidence, not glossed", + "evidence": "the CJK probe responses + the caveat note" + }, + { + "clause": "a no-hit search is a clean success: 200 with records [] and total 0 in the declared envelope — never a 4xx/5xx, never an error body", + "oracle": "api", + "verify": "the no-hit response envelope", + "evidence": "the response body" + }, + { + "clause": "the browser renders a designed empty state for a no-hit quick-search — no error toast, no console error, no stale previous rows", + "oracle": "screenshot", + "verify": "screenshot after settle shows the empty state; console capture is clean", + "evidence": "screenshot + console log" + } + ], + "negative": [ + "any wait-and-retry needed for an API-created row to become findable contradicts the query-time-expansion design — investigate as a FAIL (or a driver seam), never normalize it into the steps", + "a no-hit search answering an error envelope (or the browser toasting one) is a FAIL — absence of results is a result" + ], + "traps": ["hydration-race", "shared-browser-tab"], + "source": [ + "packages/objectql/src/search-filter.ts + packages/objectql/src/engine.ts expandSearchOnAst ($search → $or of $contains ANDed into ast.where at query time — no index artifact for latin search)", + "packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts (before-save recompute = write-through; kernel:bootstrapped backfill + rebuildSearchCompanion = the deferred reconcile paths)", + "packages/qa/dogfood/test/search-conformance.ledger.ts (Tier 2 external engines deliberately absent — there is no FTS index to be stale)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: freshness contract stated from source (query-time expansion ⇒ read-your-writes; pinyin companion write-through with boot-backfill for hook-bypassing writes) instead of assuming an index; clean no-hit behavior on both surfaces", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "search.console-global-search", + "title": "The console global search — ⌘K palette, header Search button, and /search page — drives ONE path (GET /api/v1/search): hits group under object headings, RLS hides invisible rows, Enter opens the record, empty input shows recents", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": [ + "seeded admin (admin@objectos.ai — sees all)", + "signed-up member bound to the contributor position (invoice_own_rows RLS — the same restricted persona as search.rls-both-personas)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the objectui console (app-shell) with CommandPaletteProvider — the ⌘K/Ctrl+K accelerator, the header Search button, and the ?palette=1/?cmdk=1 deep-link all drive the SAME idempotent open (ADR-0054 C1/C3)", + "the seeded CJK/pinyin rows 张伟 (zhangwei@huaning.example) and 华宁科技 (examples/app-showcase/src/data/seed/index.ts) so a CJK/pinyin query has a real hit; the search service (pinyin full-text plugin) installed so GET /api/v1/search resolves", + "seeded invoices INV-1001..INV-1008 (owner spread ada/linus/grace) and a signed-up member bound to the contributor position (sys_user_position) so the RLS-parity side has an invisible row (INV-1003) to prove absence — provisioned exactly as in search.rls-both-personas" + ], + "knownGaps": [ + "the /_console bundle is vendored and may be stale — verify the palette/page behavior against current objectui app-shell or a fresh build (stale-console-bundle, RUNNER §2)", + "when the search plugin is ABSENT, searchAll's GET /api/v1/search answers 404 and useRecordSearch degrades to the per-object find({ $search }) fanout — record WHICH path served from the network trace rather than assuming the global endpoint" + ] + }, + "steps": [ + "sign in as admin in the console; open the palette with ⌘K (Cmd/Ctrl+K), screenshot it (data-testid overlay:command-palette), then close and re-open it via the header Search button (data-testid action:command-palette:open) — confirm the SAME overlay opens (idempotent, ADR-0054 C1)", + "with the input EMPTY (after visiting a couple of records first to populate recents), confirm the palette shows the 'Recently viewed' group and fires NO search request", + "type a seeded CJK/pinyin query (e.g. 'zhangwei', '张', or 'huaning') and capture the network request — confirm it is GET /api/v1/search?q=<term> (searchAll), and capture the returned hits", + "screenshot the settled palette and confirm record hits are grouped under per-object headings (issue #3371); read the DOM only after the screenshot", + "press Enter on the top hit (ref-targeted select, not a coordinate click) and confirm navigation to /apps/<app>/<object>/record/<id> and the record page renders", + "from the palette's 'Open full search' item (navigates to <baseUrl>/search), or directly at /apps/<app>/search?q=<term>, repeat the query on the SearchResultsPage and confirm record hits grouped by object plus nav matches; also run a no-hit query and confirm the designed empty state", + "RLS parity: as the restricted member, run GET /api/v1/search?q=INV-1003 (the row their RLS hides) and the same in the palette; then as admin run the identical query — capture both persona responses", + "capture the browser console for the whole session" + ], + "acceptance": [ + { + "clause": "one open path: ⌘K, Ctrl+K, the header Search button (action:command-palette:open), and the ?palette=1/?cmdk=1 deep-link all open the SAME idempotent palette overlay (ADR-0054 C1/C3) — calling open when already open is a no-op, not a toggle-closed", + "oracle": "dom", + "verify": "each affordance yields the overlay:command-palette overlay and the URL carries the palette param; DOM checked only after a screenshot confirms the overlay rendered", + "evidence": "screenshots of the palette opened by each affordance + the URL param" + }, + { + "clause": "the palette calls the platform global-search endpoint: a seeded query issues GET /api/v1/search?q=<term> (searchAll), not merely the per-object find({ $search }) fanout — captured on the wire; if the search plugin is absent, the honest fallback is the fanout and the run records which path served", + "oracle": "network", + "verify": "the request trace shows GET /api/v1/search?q= for the typed query (or, plugin-absent, the per-object find fanout — named explicitly)", + "evidence": "the search request trace" + }, + { + "clause": "CJK/pinyin recall reaches grouped hits: 'zhangwei' / '张' / 'huaning' returns the seeded CJK record(s), listed under the object's heading — record hits grouped per object (#3371), not a flat undifferentiated list", + "oracle": "network", + "verify": "the /api/v1/search response carries the CJK hit; the palette renders it under its object heading (screenshot confirms the grouping before any DOM read)", + "evidence": "the search response + the grouped-palette screenshot" + }, + { + "clause": "Enter navigates to the record: selecting the top hit routes to /apps/<app>/<object>/record/<id> and the record page renders — the palette is a navigator, not a dead list", + "oracle": "screenshot", + "verify": "post-Enter the record page for the selected hit is on screen at the expected route (ref-targeted select rules out the automation-input trap)", + "evidence": "the record-page screenshot + the resolved URL" + }, + { + "clause": "empty input shows recents, not results: with a blank query the palette shows the 'Recently viewed' group (cloud-synced via sys_user_preference) and fires NO record-search request (minLength 2 guards the wire)", + "oracle": "network", + "verify": "no GET /api/v1/search fires for empty/1-char input; the recents group renders in the empty state", + "evidence": "the request-absence trace + the recents screenshot" + }, + { + "clause": "RLS parity both personas: the restricted member's global search returns NO hit for a row their RLS hides (INV-1003) while the identical admin query returns it — the /search path rides the same read scope as /data (parity with search.rls-both-personas); UI absence is a courtesy, the server response is the authority", + "oracle": "api", + "verify": "GET /api/v1/search?q=INV-1003 as the member yields no INV-1003 hit and leaks no count of it; as admin it returns INV-1003", + "evidence": "the paired persona responses" + }, + { + "clause": "the /search full page uses the same global-search path and empty-state: SearchResultsPage fires the same searchAll (GET /api/v1/search) and groups record hits by object; a no-hit query renders the designed empty state with no error toast or console error", + "oracle": "network", + "verify": "the page's search request is GET /api/v1/search?q=; the no-hit query yields the empty state, not an error envelope", + "evidence": "the page search trace + the empty-state screenshot + clean console" + } + ], + "negative": [ + "ANY invisible-row hit for the restricted member (or a leaked count of it) is an RLS bypass — P1 FAIL, reproduce twice and file (mirrors search.rls-both-personas)", + "record hits rendered flat with no per-object heading regresses #3371 — a FAIL against the grouped-results contract", + "an empty input firing a search request, or showing stale prior results instead of recents, is a FAIL", + "Enter selecting a hit but not navigating is a FAIL — but rule out the automation-input trap first (ref-targeted select, not a coordinate click) before recording it", + "running the deny side as admin proves nothing (wrong-persona): the RLS check must run as the non-privileged member" + ], + "variants": [ + "open via ⌘K (Cmd+K)", + "open via Ctrl+K", + "open via the header Search button (action:command-palette:open)", + "open via the ?palette=1 / ?cmdk=1 deep-link", + "surface: ⌘K palette", + "surface: /search SearchResultsPage" + ], + "traps": ["stale-console-bundle", "hydration-race", "wrong-persona", "automation-input"], + "source": [ + "objectui packages/app-shell/src/chrome/CommandPalette.tsx (⌘K palette: useRecordSearch, record hits grouped by object #3371, recents empty-state, onSelect navigate to /<object>/record/<id>, 'Open full search' → <baseUrl>/search, overlay data-testid overlay:command-palette)", + "objectui packages/app-shell/src/context/CommandPaletteProvider.tsx (ADR-0054 C1/C3: ⌘K/Ctrl+K accelerator, ?palette=1/?cmdk=1 via useUrlOverlay, idempotent openCommandPalette)", + "objectui packages/app-shell/src/layout/AppHeader.tsx (header Search button data-testid action:command-palette:open / open-mobile → openCommandPalette, ADR-0054 C1)", + "objectui packages/app-shell/src/views/SearchResultsPage.tsx (/apps/:appName/search — same searchAll path, record hits grouped by object + nav matches, designed empty state)", + "objectui packages/react/src/hooks/useRecordSearch.ts (prefers dataSource.searchAll → GET /api/v1/search; per-object find({ $search }) fanout fallback when searchAll is absent)", + "objectui packages/data-objectstack/src/index.ts (searchAll → GET /api/v1/search?q=, returns { query, hits }; 404 → empty when the search plugin is absent)", + "packages/rest/src/rest-route-ledger.ts (GET /api/v1/search, family search, source route-manager, client search) + packages/metadata-protocol/src/protocol.ts (searchAll backing)", + "search.rls-both-personas (the RLS × $search read-path mechanism this item reuses) and search.cross-field-object-search (the /data $search path this item is DISTINCT from — this tests the console global-search UI + the /search route)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: console global-search UI (⌘K palette + header button ADR-0054 C1 + /search page) over GET /api/v1/search — object-grouped hits, RLS parity, Enter-navigates, empty-shows-recents; grounded in objectui app-shell + the framework search route ledger", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json new file mode 100644 index 0000000000..03b51be3f4 --- /dev/null +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -0,0 +1,1029 @@ +{ + "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.", + "area": "studio-authoring", + "title": "Studio authoring — the admin/maker loop: packages, objects, views, record pages, draft→publish", + "items": [ + { + "id": "studio-authoring.first-run-loop", + "title": "The first-run authoring loop closes: package → object → record → app → publish → end-user, zero code, zero restarts", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "mixed", + "personas": ["admin (seeded admin@objectos.ai)", "end user (same account driving the published app)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a fresh boot of the showcase example — objectstack dev --ui --seed-admin -p <port> -d file:/tmp/<run>/data.db (the audit's environment: vendored console matching .objectui-sha, seeded admin admin@objectos.ai/admin123)" + ], + "knownGaps": [ + "publish fires in ONE click with no confirmation and the Changes panel lists items without field-level diff (audit finding 3, ADR-0016 §3.6 step 4 open) — do NOT assert a review/confirm step exists; its absence is a logged UX gap, not a run failure", + "a new app scaffolds ZERO nav items (audit finding 6) — the manual Interfaces → Add nav item → bind-to-object wiring in the steps IS the current contract; do not expect the just-built object to be pre-wired", + "mixed-language Studio chrome (audit finding 4) is asserted by i18n.studio-follows-app-locale — locale consistency is that item's business, not this one's" + ] + }, + "steps": [ + "boot the showcase example (objectstack dev --ui --seed-admin -p <port> -d file:/tmp/<run>/data.db), sign in as admin@objectos.ai/admin123, and note the boot timestamp in the server log (the zero-restart clause reads it later)", + "Home → 'Build an app' → Studio landing → create a NEW writable package (维修中心 / com.example.repairs) via the new-package wizard", + "Data pillar → new object 'Repair Ticket' (the identifier auto-suggests repair_ticket from the display name) → add a picklist field Status with 3 values → Save draft", + "open the Changes panel and Publish ('Published all drafts in this package (one atomic release)')", + "create a record in the runtime-faithful Records grid with a Status value chosen", + "Create app 'Repair Center' (identifier auto-suggested) → Interfaces pillar → add a nav item bound to repair_ticket → Publish", + "return Home: the launcher must show Repair Center; open it and drive the end-user list", + "verify server truth after each publish: GET /api/v1/meta/object/repair_ticket, GET /api/v1/data/repair_ticket, GET /api/v1/meta/app?id=<new app id>", + "negative probe: on the READ-ONLY com.example.showcase package, attempt 'New object' (or 'Add field') and drive the save through to the server; capture the server response" + ], + "acceptance": [ + { + "clause": "package create round-trips and the package switcher lists the new writable package without a page reload", + "oracle": "network", + "verify": "the create POST succeeds and the switcher shows the package in the same session (pinned by objectui e2e/live/studio-object-designer.spec.ts test F1)", + "evidence": "the create request/response + switcher screenshot" + }, + { + "clause": "the published object is server-real: the meta read returns repair_ticket with the authored picklist field carrying exactly the 3 authored options — never ticked off the designer repaint", + "oracle": "api", + "verify": "GET /api/v1/meta/object/repair_ticket body contains the Status field with its 3 options", + "evidence": "the meta read" + }, + { + "clause": "the record entered in the Studio Records grid persists to the data plane", + "oracle": "api", + "verify": "GET /api/v1/data/repair_ticket lists the record with the chosen Status value stored under the field's API name", + "evidence": "the data read" + }, + { + "clause": "the published app is live in the Home launcher and the end-user list renders the record with the picklist LABEL chip (label resolution, not the raw value)", + "oracle": "screenshot", + "verify": "launcher screenshot shows Repair Center; end-user list screenshot shows the record row with the label chip; GET /api/v1/meta/app?id=<app id> carries the nav item bound to the object", + "evidence": "the two screenshots + the app meta read" + }, + { + "clause": "the loop closes with ZERO server restarts and zero code — publish alone made the package, object, and app live (the audit's benchmark: 'Zero code, no dead ends, minutes end-to-end')", + "oracle": "log", + "verify": "the server log shows a single boot for the whole loop; no restart was performed between the first step and the end-user list", + "evidence": "server log excerpt spanning the run" + }, + { + "clause": "the writable/read-only gate is enforced SERVER-side: the authoring write into com.example.showcase is rejected by the server even where the client lets the gesture through", + "oracle": "api", + "verify": "the negative probe's server response is a rejection naming the read-only/installed-package cause (audit finding 1 proved this side holds); the client-side lock itself is access-security.readonly-package-locks-studio — cite a pass there, do not re-prove the DOM lock here", + "evidence": "the rejected write's response body" + } + ], + "negative": [ + "silent acceptance of an authoring write into a read-only package is a FAIL (ADR-0057 D10 — the server is the authoritative gate; the client lock is courtesy)", + "a 'published' app absent from the Home launcher, or an end-user list rendering raw picklist values instead of labels, is a FAIL" + ], + "traps": ["stale-console-bundle", "automation-input", "hydration-race"], + "source": [ + "docs/audits/2026-07-studio-package-create-ux-dogfood.md ('The loop closes' — the canonical walk; findings 1/3/4/6 carried as knownGaps)", + "ADR-0016 §9 (the MVP loop this proves)", + "ADR-0057 D10 (server-side gate authority)", + "access-security.readonly-package-locks-studio (client-side lock — cross-referenced, not duplicated)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new area item: the full first-run authoring loop, grounded step-by-step in the 2026-07 Studio dogfood audit ('The loop closes'), with the audit's open UX findings carried as knownGaps instead of asserted", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.object-designer-roundtrip", + "title": "Object designer round-trip: field add/edit/reorder persists to metadata, identifiers derive and survive typing", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "a writable package to author into (create one via the Studio wizard or POST /api/v1/packages — the source-loaded com.example.showcase is read-only and rejects designer saves server-side)" + ] + }, + "steps": [ + "open the object designer on the writable package: /_console/apps/com.objectstack.studio/metadata/object/new?package=<writable pkg>", + "TYPE the object name repair_asset character by character (never .fill — the F2 per-keystroke-sanitisation bug only reproduces when typed) and confirm the underscore survives", + "add a Text field, set its label to 'Asset Code', blur — the API name input must derive asset_code (F3)", + "add a Picklist field plus one EMPTY option row; wait out the debounced live validation — no validation banner may appear for the empty row (F4); then author 3 real options", + "Save draft, Publish; GET /api/v1/meta/object/repair_asset and record the full field list and its order", + "reopen the designer: rename one picklist value, drag-reorder the fields, Save draft + Publish again", + "GET /api/v1/meta/object/repair_asset again — the rename AND the new field order must be in the persisted metadata, not just the canvas", + "create a record via the Records tab and GET /api/v1/data/repair_asset/<id> — the stored column key must be the derived API name (asset_code)" + ], + "acceptance": [ + { + "clause": "designer edits persist to the metadata store: added, edited, and reordered fields read back from the meta API exactly as authored — the designer repaint is never the oracle", + "oracle": "api", + "verify": "before/after GET /api/v1/meta/object/repair_asset: field set, picklist values, and field ORDER all match the authored state after each publish", + "evidence": "the two meta reads, diffed" + }, + { + "clause": "a new field's API name derives from its label on blur, so the data column is the derived name — never a frozen field_N (the contract behind audit finding 2, where the saved record stored 'field_2': 'in_progress' forever)", + "oracle": "api", + "verify": "the field-apiname input shows asset_code after the label blur (pinned by objectui F3), and the stored record's column key is asset_code", + "evidence": "designer screenshot + the record read" + }, + { + "clause": "typed identifiers survive per-keystroke sanitisation — repair_asset keeps its underscore when typed char-by-char", + "oracle": "dom", + "verify": "after a screenshot confirms the designer rendered, the name input's value equals repair_asset (pinned by objectui F2; pre-fix this yielded 'repairasset')", + "evidence": "screenshot + input value read" + }, + { + "clause": "an empty picklist option row does not trip spec validation (no developer-facing 'System identifier must be at least 2 characters' banner), while the PUBLISHED picklist carries exactly the 3 authored options", + "oracle": "api", + "verify": "no metadata-validation-banner after the debounce (F4's negative assertion), and the meta read shows 3 options — the empty row was dropped, not persisted", + "evidence": "banner-absence screenshot + the meta read" + } + ], + "negative": [ + "an API name frozen at field_N after its label was set pre-save is audit finding 2 regressed — FAIL, but confirm against a fresh objectui build first: the vendored /_console bundle may predate the derive-on-blur fix the e2e now pins" + ], + "traps": ["stale-console-bundle", "automation-input"], + "automated": { "kind": "e2e", "ref": "objectui: e2e/live/studio-object-designer.spec.ts" }, + "source": [ + "objectui: e2e/live/studio-object-designer.spec.ts (F1–F4, the objectui#1926 regression classes: per-keystroke sanitisation, label→api-name derivation, empty picklist row, switcher refresh)", + "docs/audits/2026-07-studio-package-create-ux-dogfood.md (finding 2 — field API name did not follow the label at audit time)", + "docs/audits/2026-07-studio-package-create-ux-dogfood.md (finding 1 — read-only package rejects designer saves server-side, which is why a writable package is a fixture requirement)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: designer→metadata round-trip with the meta API as oracle, pinning the four objectui live-e2e regression classes (F1–F4) and the audit's field_N identifier finding", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.view-authoring-live", + "title": "List + form view authoring goes live in the running app on publish — no server restart", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin (authors)", "end user (consumes the published views)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a runtime-authored object in a writable package to bind views to (e.g. repair_asset from studio-authoring.object-designer-roundtrip), so no shipped showcase view is mutated" + ] + }, + "steps": [ + "author a view CONTAINER draft via the metadata draft door: PUT /api/v1/meta/view/qa_repair_asset_views?mode=draft with { object: 'repair_asset', list: { type: 'grid', columns: [...] }, form: { ... } } — the container's own keys are list/form/listViews/formViews (ViewSchema); capture the receipt", + "as an end user, open the object's list BEFORE publishing: it must still render the previously published views (or the synthesized default) — the draft is staged only", + "publish: POST /api/v1/meta/view/qa_repair_asset_views/publish", + "reload the app page (client reload allowed, NO server restart): the list must render the authored column set; open a record: the form view layout must apply", + "read back through the consumer door: GET /api/v1/meta/view?object=repair_asset (the getViewsByObject read) returns the container", + "edit the list view — reorder the columns and add one — re-publish, reload, and re-screenshot", + "capture the server log across the whole sequence (the no-restart clause reads it)" + ], + "acceptance": [ + { + "clause": "the draft save answers 200 with state:'draft' and a version (the ADR-0008 OCC token) in the receipt", + "oracle": "api", + "verify": "SaveMetaItemResponse fields per packages/spec/src/api/protocol.zod.ts: success, version, seq, state:'draft'", + "evidence": "the PUT response body" + }, + { + "clause": "pre-publish, end-user surfaces still serve the last ACTIVE views — the draft is invisible ('a draft is staged only — it is not served to the runtime until published')", + "oracle": "screenshot", + "verify": "the end-user list rendered before publish shows the OLD columns; pair with a GET of the view name showing no draft leakage", + "evidence": "pre-publish screenshot + meta read" + }, + { + "clause": "post-publish, the list renders the authored columns and the form renders the authored layout with NO server restart — a client page reload is the documented semantics (the audit's whole loop closed live inside one server session)", + "oracle": "screenshot", + "verify": "post-publish screenshots show the new list columns and form layout; the server log shows no restart between draft, publish, and render", + "evidence": "post-publish screenshots + server log excerpt" + }, + { + "clause": "the published container round-trips through the consumer read door", + "oracle": "api", + "verify": "GET /api/v1/meta/view?object=repair_asset includes qa_repair_asset_views with the authored list/form bodies", + "evidence": "the object-scoped view read" + }, + { + "clause": "edit + re-publish updates the live list (new column order renders) — the authoring loop is repeatable, not create-only", + "oracle": "screenshot", + "verify": "the second-publish screenshot shows the reordered/extended columns", + "evidence": "before/after screenshots of the list" + } + ], + "negative": [ + "a view change that needs a SERVER restart to appear is a FAIL against the audit's zero-restart benchmark", + "a draft's columns leaking into the end-user list before publish is a lifecycle FAIL (the full draft→publish contract is studio-authoring.draft-publish-lifecycle — this item only asserts the view-flavored live side)" + ], + "traps": ["stale-console-bundle", "hydration-race"], + "source": [ + "packages/spec/src/ui/view.zod.ts (container keys list/form/listViews/formViews; the 'read by getViewsByObject() / GET /meta/view?object=' binding; guidance map for wrong-layer keys)", + "packages/spec/src/api/protocol.zod.ts (SaveMetaItemResponse: state draft|active, 'staged only — not served until published')", + "docs/audits/2026-07-studio-package-create-ux-dogfood.md (publish→live launcher/list inside one server session)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: list/form view authoring through the ?mode=draft door with live-in-app verification, grounded in ViewSchema's container contract and the audit's zero-restart loop", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.record-page-roundtrip", + "title": "Record-page authoring round-trip: created bound to its object, seeded from the default layout, block-edited, published, rendered", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin (authors)", "end user (opens the record)"], + "fixtures": { + "app": "showcase", + "requires": ["seeded showcase_invoice records to render the published page against (stock showcase seed)"] + }, + "steps": [ + "open /_console/apps/showcase_app/metadata/page/new and fill the create form: Label 'Invoice Page <uniq>' (slugifies into Name), Object showcase_invoice; Save", + "capture the PUT /api/v1/meta/page/<name> request the save issues", + "assert the draft body: type:'record', object:'showcase_invoice', and regions seeded NON-empty from the object's synthesized default detail page (the page resource's createSeed hook) including a record:highlights block — never a blank canvas (ADR-0034 / objectui#1541)", + "open the page editor (/apps/showcase_app/metadata/page/<name>): Basics and Layout sections render, and 'Add block' opens the block-type picker", + "add a block (e.g. Card), Save draft, Publish through the ResourceEditPage draft/publish chrome", + "GET /api/v1/meta/page/<name>: the published regions must include the added block", + "open a showcase_invoice record as an end user: the page assignment (usePageAssignment) renders the authored page over the synthesized default", + "screenshot the editor canvas and the rendered record page" + ], + "acceptance": [ + { + "clause": "the created page persists bound to its object with PRE-SEEDED regions — record:highlights present, regions non-empty", + "oracle": "network", + "verify": "the captured PUT body has type:'record', object:'showcase_invoice', Array.isArray(regions) with blocks including record:highlights (pinned by objectui e2e/live/studio-record-page.spec.ts)", + "evidence": "the captured PUT payload" + }, + { + "clause": "the page editor exposes block authoring: the picker offers schema-backed block kinds (Card, Section, Record details)", + "oracle": "dom", + "verify": "after a screenshot confirms the editor rendered, the Add-block dialog lists the three kinds (pinned by objectui e2e/live/studio-editor.spec.ts)", + "evidence": "screenshot + dialog DOM read" + }, + { + "clause": "draft→publish round-trip: the added block is in the PUBLISHED metadata read back from the server", + "oracle": "api", + "verify": "GET /api/v1/meta/page/<name> after publish contains the added block in regions", + "evidence": "the meta read" + }, + { + "clause": "the published record page renders on a real record for the end user", + "oracle": "screenshot", + "verify": "opening a showcase_invoice record shows the authored page including the added block", + "evidence": "record-page screenshot" + } + ], + "negative": [ + "a new record page opening as a blank canvas (regions: []) is objectui#1541 regressed — the createSeed seeding is the point of the item; FAIL" + ], + "traps": ["stale-console-bundle", "hydration-race", "automation-input"], + "automated": { "kind": "e2e", "ref": "objectui: e2e/live/studio-record-page.spec.ts" }, + "source": [ + "objectui: e2e/live/studio-record-page.spec.ts (#1541, ADR-0034 — create bound + seeded regions, asserted off the PUT payload)", + "objectui: e2e/live/studio-editor.spec.ts (page editor sections + Add-block picker contract)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: record-page authoring round-trip pinned to the two objectui live e2e specs (create-seeded draft, block picker) and extended to the publish + end-user render sides they do not cover", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.draft-publish-lifecycle", + "title": "Metadata draft→publish lifecycle: drafts staged not served, publish flips visibility atomically, conflicts and invalid drafts refused, and the history/audit/diff/rollback forensics tell the truth", + "since": "v16", + "status": "active", + "revision": 2, + "priority": "P1", + "surface": "mixed", + "personas": ["admin (authors drafts)", "end user (must not see drafts)"], + "fixtures": { + "app": "showcase", + "requires": [ + "scratch metadata names only (e.g. qa_lifecycle_probe) via the draft door PUT /api/v1/meta/<type>/<name>?mode=draft — no shipped showcase file is touched", + "a writable package with two stageable drafts for the package-wide publish-drafts door" + ] + }, + "steps": [ + "author a dashboard draft: PUT /api/v1/meta/dashboard/qa_lifecycle_probe?mode=draft with one dataset-bound widget (dataset: showcase_task_metrics, dimensions: ['status'], values: ['task_count']) — the same door areas/dashboards.json exercises", + "record the receipt: { success, version, seq, state: 'draft' }", + "BOTH sides pre-publish: GET /api/v1/meta/dashboard/qa_lifecycle_probe as the runtime read → 404 (never published) — the draft body must not be served; in Studio the item shows the 'Unpublished draft' badge and the 'Changes · n' counter", + "publish per-ref: POST /api/v1/meta/dashboard/qa_lifecycle_probe/publish → 200; GET now serves the authored body", + "author a SECOND draft revision on the same name and confirm the live read keeps serving revision 1 until that draft is published", + "concurrency guard: re-PUT with the STALE version as If-Match and capture the 409", + "package-wide door: stage two drafts in the writable package and POST /api/v1/packages/<pkg id>/publish-drafts — both flip in one atomic release (the audit's 'Published all drafts in this package (one atomic release)' toast)", + "author an INVALID draft (a widget carrying a stray legacy key) and attempt its publish — the author-time gate must reject the draft→active transition (#4463)", + "meta forensics (after the two publishes of qa_lifecycle_probe — revision 1, then the second revision): GET /api/v1/meta/dashboard/qa_lifecycle_probe/history — the durable sys_metadata_history events must list BOTH published revisions (a dashboard is an overlay type, so history is real; a non-overlay type answers { events: [] } by design)", + "GET /api/v1/meta/dashboard/qa_lifecycle_probe/diff?from=1&to=2 (or omit the params for previous-vs-current) — the structural diff must name the widget key that changed between the revisions, not dump the whole body", + "POST /api/v1/meta/dashboard/qa_lifecycle_probe/rollback with { toVersion: 1 } (send X-Actor or drive it under an authenticated session so the actor is attributable); the runtime GET must then serve revision 1's body again and the rendered dashboard must show revision 1's widget", + "attempt a rollback with a missing/invalid toVersion and capture the 400 INVALID_REQUEST guard", + "GET /api/v1/meta/dashboard/qa_lifecycle_probe/audit — the save/publish/rollback rows must carry the acting user; run every forensics probe against the REST route-manager server os dev serves, NEVER a simulated dispatch (the dispatcher /meta branch swallows /history as a compound name and 404s)" + ], + "acceptance": [ + { + "clause": "a draft save answers state:'draft' and is staged only — the runtime read does not serve it (both sides captured: receipt + 404/last-active read)", + "oracle": "api", + "verify": "PUT receipt has state:'draft' (SaveMetaItemResponseSchema); the follow-up GET returns 404 (or the last ACTIVE body for a previously-published name), never the draft body", + "evidence": "receipt + pre-publish GET" + }, + { + "clause": "publish flips visibility exactly once: the post-publish read serves the authored body; the pre-publish read never did", + "oracle": "api", + "verify": "POST /meta/dashboard/qa_lifecycle_probe/publish → 200, then GET returns { type, name, item } with the widget intact", + "evidence": "publish response + post-publish GET" + }, + { + "clause": "a pending second draft does not perturb the live revision until published", + "oracle": "api", + "verify": "after staging draft 2, GET still returns revision 1's body; after publishing draft 2, GET returns revision 2", + "evidence": "the three reads" + }, + { + "clause": "optimistic concurrency holds: a write carrying a stale If-Match version answers 409 metadata_conflict — a concurrent edit is reported, never silently overwritten", + "oracle": "api", + "verify": "re-PUT with the superseded version token → 409 with the metadata_conflict code (the ADR-0008 chain the receipt's version field exists for)", + "evidence": "the 409 response" + }, + { + "clause": "package-wide publish-drafts promotes every pending draft in one atomic release — and a non-compliant draft (e.g. an object draft missing the package namespace prefix) aborts the batch BEFORE any promotion", + "oracle": "api", + "verify": "POST /api/v1/packages/<id>/publish-drafts flips both staged drafts; the namespace-gate rejection path leaves ALL drafts unpromoted (packages/objectql/src/protocol-publish-package-drafts.test.ts pins the atomicity)", + "evidence": "the publish-drafts response + post-state reads" + }, + { + "clause": "an invalid draft cannot cross into active: the author-time rules gate the draft→active transition (#4463) — publish of the stray-key draft is refused", + "oracle": "api", + "verify": "the publish attempt on the invalid draft errors; the live read still serves nothing (or the prior good revision)", + "evidence": "the refused publish + follow-up GET" + }, + { + "clause": "Studio chrome tells the truth: 'Unpublished draft' badge and 'Changes · n' counter while pending, cleared after publish", + "oracle": "screenshot", + "verify": "badge/counter visible pre-publish, gone post-publish (the audit found this model 'reads consistently everywhere')", + "evidence": "before/after Studio screenshots" + }, + { + "clause": "the durable history lists BOTH published revisions after two publishes — GET /meta/dashboard/qa_lifecycle_probe/history returns the sys_metadata_history events for revision 1 and revision 2 (real events because a dashboard is an overlay type; a non-overlay type returning { events: [] } is by design, not a miss)", + "oracle": "api", + "verify": "the /history body carries two version entries with ascending seq/version — consulted on the REST route-manager server, not the dispatcher", + "evidence": "the history response" + }, + { + "clause": "diff names the changed key, not a whole-body dump — GET .../diff (from=1&to=2, or previous-vs-current) isolates the widget/dimension key that differs between the two revisions", + "oracle": "api", + "verify": "the diff body reports the changed path (the edited widget key), not the entire document", + "evidence": "the diff response" + }, + { + "clause": "rollback restores revision 1 AND the live app serves it — POST .../rollback { toVersion: 1 } → 200, the runtime GET then serves revision 1's body, and the rendered dashboard shows revision 1's widget; a missing/invalid toVersion answers 400 INVALID_REQUEST, never a silent no-op", + "oracle": "api", + "verify": "post-rollback GET /meta/dashboard/qa_lifecycle_probe returns revision 1's body (a screenshot of the reverted widget corroborates the served side); the bad-toVersion attempt returns 400 INVALID_REQUEST", + "evidence": "the rollback response + the post-rollback GET + the reverted-render screenshot + the 400 response" + }, + { + "clause": "audit rows carry the actor — GET .../audit lists the save/publish/rollback attempts (allowed and denied) each stamped with the acting user, resolved from X-Actor / the session identity, never anonymous", + "oracle": "api", + "verify": "the /audit body's rows for this name include the publish and rollback actions with a non-empty actor field", + "evidence": "the audit response" + } + ], + "negative": [ + "a draft body served to end users before publish is the lifecycle FAIL this item exists for", + "a publish that answers 200 while the live read still serves the old body is a FAIL — the ADR-0045 visibility flip failing loudly is exactly the path packages.ts warns about, and silence there is worse than the warning", + "a forensics route consulted on the dispatcher instead of the REST route-manager server is a recording error — the dispatcher /meta branch swallows /history as a compound name and 404s (rest-route-ledger.ts note); the oracle is the live server os dev serves", + "a rollback that answers 200 while the live read still serves the newer revision is a FAIL (the restore must actually flip the served body)" + ], + "traps": ["hydration-race", "dispatcher-vs-hono-route"], + "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts" }, + "source": [ + "packages/spec/src/api/protocol.zod.ts (SaveMetaItemResponse: state draft|active, version as If-Match/409 OCC token, 'staged only — not served until published')", + "packages/runtime/src/domains/packages.ts (POST /packages/:id/publish-drafts, ADR-0033/ADR-0045 visibility flip + its failure warning)", + "packages/objectql/src/protocol-publish-package-drafts.test.ts (atomic namespace gate; #4463 author-time rules gate the draft→active transition)", + "packages/rest/src/rest-server.ts (GET /meta/:type/:name/{history,audit,diff} + POST .../rollback: overlay-type history vs { events: [] } for non-overlay; rollback body { toVersion }, 400 on a missing/invalid toVersion; actor from X-Actor / session)", + "packages/rest/src/rest-route-ledger.ts (client bindings meta.getHistory / getAudit / diffItem / rollbackItem; the 'dispatcher /meta swallows /history as a compound name and 404s' note — routes hunter #12)", + "docs/audits/2026-07-studio-package-create-ux-dogfood.md ('Unpublished draft' badge, 'Changes · n', 'one atomic release' toast)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: both sides of the draft→publish gate (staged-not-served / flipped-on-publish), plus OCC 409, atomic package-wide publish, and the #4463 invalid-draft publish refusal — grounded in the spec receipt schema, the runtime publish-drafts handler, and the pinned dogfood roundtrip", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-08", "change": "clause-extension (routes hunter #12): meta forensics — GET /meta/:type/:name/{history,audit,diff} + POST .../rollback (two publishes → history lists both, diff names the changed key, rollback restores rev-1 and the live app serves it, audit rows carry the actor); traps gain dispatcher-vs-hono-route (the dispatcher /meta branch 404s /history)", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.authoring-validation-not-persisted", + "title": "An invalid authored shape is rejected at save with a LOCATED error and is not persisted", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": ["scratch metadata names only (qa_invalid_probe / qa_invalid_views) — rejected drafts must leave nothing behind, so no cleanup dependency"] + }, + "steps": [ + "attempt PUT /api/v1/meta/object/qa_invalid_probe?mode=draft with a field MISSING its type; capture the status and full error body", + "attempt PUT /api/v1/meta/view/qa_invalid_views?mode=draft with `type`/`columns` at the CONTAINER level (the flat-list-view mistake); capture the guidance error", + "after each rejection, GET the name back and record what the store holds", + "drive one invalid shape through the Studio designer (browser door) and capture the surfaced error — it must locate the offending field, not say a generic 'save failed'", + "author the CORRECTED shapes (field with a type; views wrapped under list:) and confirm both save 200 — the gate rejects the shape, not the name", + "dashboard-kind stray keys are deep-covered by dashboards.strict-widget-rejects-stray-keys — cite a pass there rather than re-enumerating the 11 legacy keys here" + ], + "acceptance": [ + { + "clause": "an invalid object shape is rejected at save with a located error naming the failing path (the '[invalid_metadata] … fields.<name>.type: Required' shape)", + "oracle": "api", + "verify": "the rejection is 4xx and its body names the exact field path that failed spec validation", + "evidence": "the error body" + }, + { + "clause": "a wrong-layer view container key is rejected with guidance naming where the key belongs ('`type` belongs to a single VIEW, not to the container. Wrap it: defineView({ list: { … } }) …')", + "oracle": "api", + "verify": "the error text for container-level type/columns carries the ViewSchema guidance-map prescription, giving the author the fix", + "evidence": "the error text" + }, + { + "clause": "rejected drafts are NOT persisted — the rejection is authoritative, not cosmetic", + "oracle": "api", + "verify": "GET after each rejected PUT returns 404 (or the last GOOD revision for a pre-existing name), never a body containing the invalid shape", + "evidence": "the GET responses paired with each rejected PUT" + }, + { + "clause": "the browser door surfaces the same rejection visibly — a located error banner/toast, never a silent dead Save", + "oracle": "screenshot", + "verify": "the Studio save attempt shows the validation error naming the offending field", + "evidence": "the error-state screenshot" + }, + { + "clause": "the corrected shapes save 200 — the gate is precise about the shape, not the operation", + "oracle": "api", + "verify": "both corrected PUTs succeed with state:'draft' receipts", + "evidence": "the two success receipts" + } + ], + "negative": [ + "a 2xx on an invalid draft, or a rejection that leaves the invalid body readable afterwards, is a FAIL", + "silent client-side swallowing — no visible error after a failed save — is a FAIL even though the server refused correctly (the author must SEE the located error)" + ], + "traps": ["stale-console-bundle", "automation-input"], + "source": [ + "packages/runtime/src/http-dispatcher.test.ts (the located '[invalid_metadata] object/bad failed spec validation: fields.amount.type: Required' error shape)", + "packages/spec/src/ui/view.zod.ts (container guidance map: type/columns/data/viewKind/filters/sort each name the wrap prescription)", + "dashboards.strict-widget-rejects-stray-keys (dashboard-kind stray keys — cross-referenced, not duplicated)" + ], + "history": [ + { "revision": 1, "date": "2026-08-07", "change": "new item: authoring validation with located errors and verified non-persistence, sampling object + view kinds and cross-referencing the deepened dashboard stray-key item instead of duplicating it", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.org-override-registry-gate", + "title": "The metadata type registry gates runtime writes: allowOrgOverride=false kinds refuse overlay (403 not_overridable), allowRuntimeCreate=false kinds refuse creation — both sides", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "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)" + ] + }, + "steps": [ + "read the authority first: DEFAULT_METADATA_TYPE_REGISTRY in packages/spec/src/kernel/metadata-plugin.zod.ts declares per-kind allowOrgOverride and allowRuntimeCreate — if the flags have changed, revise this item's variants before running", + "positive overlay: PUT /api/v1/meta/view/<a packaged showcase view name> with a modified label (view: allowOrgOverride true) → expect acceptance; GET the name and confirm the overlay wins at read", + "negative overlay: PUT /api/v1/meta/object/showcase_task (artifact-backed; object: allowOrgOverride false) with any modified body → capture the refusal", + "negative create: PUT /api/v1/meta/job/qa_probe_job (job: allowRuntimeCreate false — a runtime-authored job could never be scheduled, #4509) → capture the refusal", + "positive create: PUT /api/v1/meta/object/qa_org_probe as a brand-NEW object (object: allowRuntimeCreate true) → expect acceptance — the two flags gate different doors", + "cleanup + reset semantics: DELETE /api/v1/meta/view/<overlaid name> and confirm reset:true, then GET returns the artifact default again", + "run every probe against the LIVE server os dev runs — never a simulated dispatch" + ], + "acceptance": [ + { + "clause": "a kind with allowOrgOverride=false refuses the org-level override of an artifact-backed item with 403 not_overridable — the lock is server-side", + "oracle": "api", + "verify": "PUT /api/v1/meta/object/showcase_task answers 403 with the not_overridable code (the runtime behavior the registry's own doc comment declares)", + "evidence": "the 403 response body" + }, + { + "clause": "an overlay-enabled kind accepts the write AND the overlay takes precedence at read — the other side of the same gate", + "oracle": "api", + "verify": "the packaged view accepts the overlay PUT and the follow-up GET serves the overlaid label (overlay-precedence)", + "evidence": "the accepted write + the read showing the overlay" + }, + { + "clause": "a kind with allowRuntimeCreate=false refuses creation with 403 (not_creatable) — declared-but-inert metadata is refused at the door, not stored to never run", + "oracle": "api", + "verify": "PUT /api/v1/meta/job/qa_probe_job answers 403 not_creatable (job's flags exist precisely because a runtime job's handler could never resolve — ADR-0049)", + "evidence": "the 403 response" + }, + { + "clause": "runtime-creatable kinds accept a brand-new item — locked-override and locked-create are independent gates (object: override locked, create open)", + "oracle": "api", + "verify": "the new-name object PUT is accepted while the artifact-backed object PUT was refused, in the same run", + "evidence": "the paired responses" + }, + { + "clause": "deleting the overlay row resets to the artifact default: reset:true when a row was removed, reset:false when none existed", + "oracle": "api", + "verify": "DELETE answers per DeleteMetaItemResponseSchema and the follow-up GET serves the artifact body again", + "evidence": "the delete response + post-delete read" + } + ], + "negative": [ + "a 200 on an overlay write against an artifact-backed object/field is the FAIL this registry exists to prevent (per-org schema drift and upgrade conflicts — the rationale written into the registry entry itself)" + ], + "variants": [ + "object (override locked, create open)", + "field (override locked, create open)", + "job (create locked — 403 not_creatable)", + "view (override open)", + "dashboard (override open)" + ], + "traps": ["dispatcher-vs-hono-route"], + "automated": { "kind": "unit", "ref": "packages/objectql/src/overlay-precedence.test.ts" }, + "source": [ + "packages/spec/src/kernel/metadata-plugin.zod.ts (DEFAULT_METADATA_TYPE_REGISTRY per-kind flags; allowOrgOverride doc: 'runtime returns HTTP 403 not_overridable'; the object/field lock rationale; job's #4509 create lock)", + "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)" + ], + "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" } + ] + }, + { + "id": "studio-authoring.expression-editors", + "title": "The Studio CEL editors reach the SAME verdict as the engine: formula result-type inference, previous. completion in conditional rules, and RLS lint + test-run — the editor UI, not a second grammar", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "mixed", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "a writable package with an object carrying a formula field (role=value) and a field conditional rule (visibleWhen/readonlyWhen/requiredWhen) — the stock showcase formula shapes are the models to author against (showcase_invoice line_total = record.quantity * record.unit_price, showcase_project remaining-budget, field-zoo); the source-loaded com.example.showcase is read-only, so the SAVE round-trip needs a writable package (the editors' lint/autocomplete/inferred-type/test-run themselves run client-side and can be OBSERVED read-only on the showcase fields)", + "a writable permission set to host the RLS USING/CHECK CEL editor + test-run (PermissionAdvancedFacets)", + "the @objectstack/formula engine present in the console bundle — celAuthoring lazy-loads it" + ], + "knownGaps": [ + "the CEL editors lazy-load @objectstack/formula and FEATURE-DETECT every entry point (celAuthoring.ts) — a missing/older engine degrades to no lint / no suggestions / test-run 'unavailable', never an exception. If the vendored bundle lacks the engine, record the affected clauses blocked(dependency) and verify the graceful-degradation clause instead" + ] + }, + "steps": [ + "ground the engine truth FIRST (rule 6 parity): for each probe expression, capture the canonical verdict from the engine — via MCP validate_expression (site: formula / flow_condition) or `objectstack build` — so the editor is judged against server truth. The ENGINE itself is pinned by ai.mcp-validate-expression / api-backend.formula-gates; this item only checks the EDITOR agrees", + "F1 formula result-type: open the object field designer (ObjectFieldInspector) on a formula field (type=formula, role=value, scope=record), TYPE a proven-number expression (record.est_hours * 1.1, or record.quantity * record.unit_price) and, separately, one cel-js cannot prove (record.a + record.b); after the 250ms debounce, screenshot the inferred-result-type (Σ) affordance for each", + "type an unknown field (record.est_hourz) and a bare field ref in record scope (est_hours, no record. prefix); capture the inline error under the field (aria-invalid, destructive border) and its record.<field> fix text", + "F2 conditional-rule editor: open the same field's visibleWhen editor (scope=record, roots record/previous/parent); type `previous.` and then `record.` and capture the autocomplete listbox — it must offer the object's field catalog; type `current_user.` and confirm it does NOT list this object's fields; accept a suggestion and confirm the insertion", + "F4 RLS editor: open the permission set's Advanced Facets RLS policy (PermissionAdvancedFacets), enter a USING predicate that is NOT a simple field comparison (blast-radius) and one with a parse fault; confirm the non-pushdownable filter raises a WARNING (Save stays enabled) while the parse fault raises a blocking ERROR that disables Save (celErrorCount>0 → title perm.cel.saveBlocked)", + "RLS test-run: open the CelTestRunDialog ('Test this policy against a sample record'), supply a sample record + current_user, Run; capture allow (shield-check) / deny (shield-x) / a non-boolean value (non-bool smell) across three sample+predicate pairs", + "parity cross-check: for the SAME expressions, confirm the editor's verdict tier (error/warning/ok, inferred type, allow/deny) equals the engine verdict captured in step 1", + "persistence round-trip (writable package): Save the edited formula field → GET /api/v1/meta/object/<obj> shows Field.returnType stamped from the inferred type; Save the RLS policy → GET /api/v1/meta/permission/<name> carries the USING/CHECK predicate" + ], + "acceptance": [ + { + "clause": "the formula editor (role=value, scope=record) shows the inferred result type after the debounced lint — 'Number' for a proven-number formula, 'unknown' for record.a + record.b (cel-js cannot prove number vs string) — surfaced as the Σ affordance, not guessed", + "oracle": "screenshot", + "verify": "the inferred-type line reads Number for the proven formula and unknown (with the double()/int() hint) for the unprovable one", + "evidence": "the two inferred-type screenshots" + }, + { + "clause": "the inferred type stamps Field.returnType on save, but ONLY when the author actually edited the formula this session — GET /meta/object/<obj> shows returnType equal to the inferred type; a formula field left un-edited this session keeps its prior returnType (the formulaEdited guard prevents clobbering)", + "oracle": "api", + "verify": "the meta read after saving an edited formula carries returnType = the inferred type; an un-edited sibling formula's returnType is unchanged", + "evidence": "the meta read" + }, + { + "clause": "the editor's lint TIERS equal the canonical engine's — an unknown field and a bare field ref in record scope are ERRORS (with the record.<field> fix, blocking Save), a non-pushdownable USING read filter is a WARNING; the editor calls the SAME @objectstack/formula validateExpression the server and validate_expression use (ADR-0032), so the verdict is identical, not a second grammar", + "oracle": "api", + "verify": "for each probe, the editor's inline finding severity matches validate_expression / build for the same expression+site (error vs warning vs ok)", + "evidence": "the paired editor screenshot + engine verdict, per probe" + }, + { + "clause": "record. / previous. member completion offers the object's field catalog, while suppressed roots (current_user, parent) do NOT offer this object's fields, and a bare field ref in record scope is withheld from bare completion and flagged as an error — because a bare ref silently evaluates to null at runtime", + "oracle": "dom", + "verify": "after a screenshot confirms the editor rendered, the autocomplete listbox after 'previous.' / 'record.' lists field names; after 'current_user.' it does not list this object's fields; a bare 'est_hours' shows the record.<field> error", + "evidence": "the autocomplete DOM reads + the bare-ref error" + }, + { + "clause": "the RLS blast-radius advisory and the Save gate work together: a non-pushdownable USING read filter raises the fail-open WARNING (advisory, Save stays enabled), while a parse fault raises a blocking ERROR that disables Save (celErrorCount>0, title perm.cel.saveBlocked) — a malformed predicate cannot be persisted", + "oracle": "screenshot", + "verify": "the non-pushdownable filter shows the widen-access warning with Save still enabled; a parse-faulted predicate disables Save", + "evidence": "the two editor states" + }, + { + "clause": "test-run dry-runs through the SAME engine the server uses: a predicate that should allow the sample returns allow, one that should deny returns deny, and a non-boolean returns the value/non-bool smell — matching a direct engine evaluate of the same predicate+scope", + "oracle": "screenshot", + "verify": "allow / deny / value outcomes for three sample+predicate pairs, each matching the engine's own verdict", + "evidence": "the three outcome banners" + }, + { + "clause": "editor↔engine parity is the whole point (rule 6): for every probe the editor's verdict equals the engine verdict from ai.mcp-validate-expression / api-backend.formula-gates — this item does NOT re-prove the engine, it proves the EDITOR reaches the identical verdict rather than maintaining a second grammar (ADR-0032)", + "oracle": "api", + "verify": "the per-probe editor verdicts equal the engine verdicts captured up front; cite a pass of ai.mcp-validate-expression for the engine side", + "evidence": "the parity table" + }, + { + "clause": "when @objectstack/formula cannot load the editors degrade to no-lint / no-suggestions / test-run 'unavailable', never an exception that breaks the form (feature-detect + swallow)", + "oracle": "test", + "verify": "run objectui packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts (unavailable-engine cases) and cite its output — do not hand-break the bundle in the browser", + "evidence": "the test output" + } + ], + "negative": [ + "an editor verdict that DIFFERS from the engine (validate_expression / build) for the same expression is a FAIL — a second grammar in the GUI is exactly what celAuthoring exists to prevent (ADR-0032)", + "a parse-fault formula or RLS predicate that Saves anyway (Save not gated on celErrorCount) is a FAIL — a malformed RLS predicate silently mis-scopes rows and some evaluation paths FAIL OPEN, widening access with no error", + "record. / previous. completion offering an unbound root's members, or a bare field ref not flagged in record scope, is a FAIL — it authors a predicate that silently never fires / evaluates to null" + ], + "variants": [ + "formula field expression (role=value, scope=record) — inferred result type", + "field conditional rule visibleWhen / readonlyWhen / requiredWhen (scope=record, roots record/previous/parent) — previous. completion", + "RLS USING read filter (pushdown / fail-open advisory)", + "RLS CHECK write filter" + ], + "traps": ["stale-console-bundle", "hydration-race", "automation-input"], + "automated": { "kind": "unit", "ref": "objectui: packages/app-shell/src/views/metadata-admin/celAuthoring.test.ts (+ CelPredicateField.test.tsx, CelTestRunDialog.test.tsx, PermissionAdvancedFacets.cel.test.tsx)" }, + "source": [ + "objectui: packages/app-shell/src/views/metadata-admin/celAuthoring.ts (the bridge to @objectstack/formula — the SAME parser/validator the server and the validate_expression agent tool use, ADR-0032; lintCelPredicate / introspectCelScope / testRunCelPredicate / inferCelValueType; lazy feature-detected, error-swallowing degradation)", + "objectui: packages/app-shell/src/views/metadata-admin/CelPredicateField.tsx (inline lint, as-you-type autocomplete incl. record./previous. member completion via FIELD_MEMBER_ROOTS, role=value inferred-result-type affordance, aria-invalid on parse error)", + "objectui: packages/app-shell/src/views/metadata-admin/CelTestRunDialog.tsx (USING/CHECK dry-run allow/deny/value/unavailable through the server's own engine)", + "objectui: packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx (formula role=value scope=record → Field.returnType stamped from the inferred type ONLY once edited; FIELD_RULE_ROOTS record/previous/parent for conditional rules)", + "objectui: packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx (RLS facet hosts CelPredicateField + CelTestRunDialog; onCelErrorsChange → celErrorCount gates Save)", + "ai.mcp-validate-expression + api-backend.formula-gates (the ENGINE parity — cross-referenced; this item only proves the editor agrees)", + "objectui#2413 (RLS CEL editor + test-run), objectui#1582 (conditional-rule/formula scope + inferred type), ADR-0032 (one CEL engine across GUI/SDK/CLI)", + "examples/app-showcase/src/data/objects/invoice.object.ts + project.object.ts + field-zoo.object.ts (stock formula-field expressions to author against)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: the Studio CEL editor UI (formula result-type inference, previous. completion in conditional rules, RLS lint + test-run) round-tripping to the SAME @objectstack/formula verdicts the engine gives — grounded in celAuthoring.ts + CelPredicateField/CelTestRunDialog and cross-referencing the engine-parity items rather than re-proving them", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.permission-matrix-editor-ux", + "title": "Permission-matrix editor UX: field-filter + bulk apply to EXACTLY the visible fields, and the Bulk column never clips at narrow widths", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["admin"], + "fixtures": { + "app": "showcase", + "requires": [ + "a writable permission set to edit in the Permission Matrix editor (env-scope metadata-admin, or a package permission set in the Studio Access pillar) — a read-only source-loaded set can be opened to verify the read-only gate but not to exercise bulk writes", + "an object with MORE THAN 6 fields to expand in the matrix, so the field sub-table's filter renders (FieldsSubTable shows the filter only when fields.length > 6) — a stock showcase object with a wide field set (showcase_account / showcase_invoice)", + "a narrow viewport for the Bulk-column clip check (B3)" + ] + }, + "steps": [ + "open the Permission Matrix editor on a writable permission set (Studio Access pillar, or /_console/apps/com.objectstack.studio/metadata/permission/<name>); expand an object with > 6 fields so the field sub-table's filter renders (threshold is > 6)", + "B4 field filter: type a filter that narrows the field set to a couple of fields; confirm the sub-table's 'n / N' counter and the DOM show only the matching fields (the hidden fields' read/edit checkboxes are absent)", + "B4 bulk-over-visible: click 'Write all' (perm.field.bulk.writable) → it must grant read+write to EXACTLY the visible fields; clear the filter and confirm the previously-hidden fields are UNTOUCHED (still at their prior grant), never bulk-applied", + "on a different filtered set click 'Read all' → it revokes write on the visible fields only; click 'Clear' → it drops the explicit overrides so the visible fields fall back to the read-only default (readable, not editable)", + "Save; GET /api/v1/meta/permission/<name> (or the package draft read) and assert the fields map (keyed `${object}.${field}`) carries overrides for ONLY the fields that were visible when each bulk fired — never a filter-hidden field", + "B3 clip: shrink the viewport to a narrow width; confirm the object matrix's enclosing overflow-auto container SCROLLS horizontally (the table carries min-w-[960px]) and the Bulk column (Read / CRUD / All / None per row) stays reachable, not clipped off the right edge", + "read-only gate: open a read-only package/type set; confirm Save is hidden and the bulk buttons + checkboxes are disabled (the writable gate), and the read-only badge names package-vs-type as the reason", + "the grant→access flip (a flipped verb actually changing a persona's API access) is access-security.permission-matrix-edit-loop — cite a pass there; this item does NOT re-prove that a saved grant changes access" + ], + "acceptance": [ + { + "clause": "the field filter narrows the sub-table to the matching fields only — the visible/total counter and the DOM both reflect the filtered set (hidden fields' read/edit checkboxes absent)", + "oracle": "dom", + "verify": "after a screenshot confirms the sub-table rendered, only the filtered field rows are present and the 'n / N' counter matches the filtered count", + "evidence": "the filtered sub-table DOM read + screenshot" + }, + { + "clause": "a field bulk (Write all / Read all) applies to EXACTLY the visible/filtered fields — after the bulk, clearing the filter shows the previously-hidden fields still at their prior grant, so the wrong-scope write never happened (objectui#2600 B4)", + "oracle": "dom", + "verify": "filter → bulk → clear filter: the hidden fields' read/edit checkbox states are unchanged from before the bulk", + "evidence": "before/after checkbox states across the filter clear" + }, + { + "clause": "server truth proves the scope: the SAVED permission set's fields map (keyed `${object}.${field}`) carries overrides for ONLY the fields visible at bulk time — a filter-hidden field must not appear from a bulk it was never part of", + "oracle": "api", + "verify": "GET /meta/permission/<name>: the fields-map keys touched by a bulk are a subset of the field names visible when that bulk fired", + "evidence": "the meta read" + }, + { + "clause": "'Clear' drops the explicit overrides for the visible fields so they fall back to the default (readable, not editable) — the saved fields map no longer carries those keys, rather than writing an explicit default row", + "oracle": "api", + "verify": "after Clear + Save, the cleared fields' keys are absent from the fields map", + "evidence": "the meta read" + }, + { + "clause": "at a narrow viewport the object matrix container scrolls horizontally and the Bulk column (Read / CRUD / All / None) stays reachable — the min-w-[960px] table forces a scrollbar instead of clipping the last column off the right edge (objectui#2600 B3)", + "oracle": "screenshot", + "verify": "a narrow-width screenshot shows a horizontal scrollbar and, after scrolling right, the full Bulk column", + "evidence": "the narrow-viewport screenshot(s)" + }, + { + "clause": "the read-only gate holds: a read-only package/type hides Save and disables every bulk button + checkbox; the read-only badge names the reason (package vs type)", + "oracle": "screenshot", + "verify": "on the read-only set Save is absent, bulk buttons are disabled, and the badge text distinguishes the package gate (engine.studio.pkg.readonly) from the type gate (perm.readOnly)", + "evidence": "the read-only screenshot" + } + ], + "negative": [ + "a field bulk that writes grants to fields hidden by the filter is the FAIL this item exists for — a wrong bulk scope silently grants or revokes access on fields the admin never saw (assert against the SAVED fields map, not the repaint)", + "the Bulk column clipped off the right edge at a narrow width with no horizontal scroll is the B3 FAIL — the admin cannot reach Read / CRUD / All / None" + ], + "variants": [ + "field bulk: Read all (readable)", + "field bulk: Write all (writable)", + "field bulk: Clear" + ], + "traps": ["stale-console-bundle", "hydration-race", "automation-input"], + "automated": { "kind": "unit", "ref": "objectui: packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldBulk.test.tsx (+ PermissionMatrixEditor.readonly.test.tsx)" }, + "source": [ + "objectui: packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx (FieldsSubTable B4 — field filter + bulkSetFields over visibleNames; PermissionTable min-w-[960px] B3 anti-clip on the Bulk column; the writable gate + celErrorCount Save gate)", + "objectui: packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.fieldBulk.test.tsx ('the filter narrows the field set and bulk acts only on what is visible'; 'Clear drops the overrides so fields fall back to the read-only default')", + "objectui: packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.readonly.test.tsx (the read-only package/type gate)", + "#3358 §5 B3/B4 (permission-matrix editor UX: Bulk column clip; field-filter + bulk over the visible set)", + "access-security.permission-matrix-edit-loop (the grant→access flip — cross-referenced, not duplicated)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: permission-matrix editor UX — field-filter + bulk apply scoped to exactly the visible fields (wrong scope writes wrong grants) and the Bulk column's anti-clip min-width, grounded in PermissionMatrixEditor.tsx + its fieldBulk/readonly tests; cross-refs the permission-matrix-edit-loop for the grant→access side", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.custom-page-render-and-blocks", + "title": "Declarative custom pages render their real block COMPOSITION (not just 'no error'): each declared block resolves its renderer and binds its data source, data-bound blocks show seeded rows (API cross-check), and a block that cannot bind degrades with a NAMED error, never a blank region", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["admin (seeded admin@objectos.ai) driving the published showcase app as an end user"], + "fixtures": { + "app": "showcase", + "requires": [ + "a fresh boot of the showcase with seeded data present — GET /api/v1/data/showcase_project · /showcase_task · /showcase_account must each return rows, and the chart datasets showcase_task_metrics / showcase_project_metrics (examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts) must resolve (seed-data-thin guard: an empty seed makes every data-bound block look 'broken')", + "a scratch page name (e.g. qa_page_probe) authorable via the draft door PUT /api/v1/meta/page/qa_page_probe?mode=draft — used ONLY for the degradation probe, so no shipped showcase page is mutated" + ], + "knownGaps": [ + "objectui e2e/live/showcase-smoke.spec.ts sweeps only a SUBSET of these pages (My Work, Project Workspace, Active Projects, Component Gallery are in its SURFACES list; Command Center 大屏 / showcase_command_center is NOT) and only asserts render-clean (no pageerror / no placeholder leak / non-empty <main>). This item DEEPENS that shallow pin: it asserts the block composition and the data binding, which the smoke does not — so it is NOT marked `automated`.", + "command-center's KPIs read via the console adapter's aggregate path; where the exact aggregate endpoint differs from a plain GET, cross-check the tile against the same filter applied to the data plane (count of the matching rows), not against a pixel." + ] + }, + "steps": [ + "boot the showcase, sign in admin@objectos.ai/admin123, and confirm the seed is not thin: GET /api/v1/data/showcase_project, /showcase_task, /showcase_account each return rows (record the counts — the data-bound clauses read them back)", + "Command Center (大屏): open /_console/apps/showcase_app/page/showcase_command_center (the objectui smoke drives the package-id form /apps/com.example.showcase/page/showcase_command_center); screenshot the full-bleed board FIRST (confirm render), THEN read the region DOM", + "object-metric KPIs (kind:'full', variant:'bare'): read the 6 hero tiles and cross-check them against the data plane — '活跃项目 Active' == count of showcase_project where status=active, '待办任务 Open' == count of showcase_task where status != done, '风险项目 At-Risk' == count of showcase_project where health=red (aggregate authored in command-center.page.ts)", + "object-chart (bar / donut / area) bound to datasets: confirm each chart drew a sized SVG (no recharts width(-1)/height(-1) collapse) with more than one bucket, and cross-check the '任务状态分布' bar buckets against the showcase_task_metrics dataset aggregation (dimensions ['status'], values ['task_count'])", + "object-grid (待审核列表 work queue) bound to showcase_task: the grid's rows and its columns (title/project/status/priority/due_date) match GET /api/v1/data/showcase_task", + "My Work: open /_console/apps/showcase_app/page/showcase_my_work — the personal object-grid is filtered `owner_id = {current_user_id}`; cross-check its rows against GET /api/v1/data/showcase_task filtered to the signed-in user's id, and confirm the page:card 'Leadership View' (visible: user.email == 'admin@objectos.ai') renders for admin", + "Project Workspace: open /_console/apps/showcase_app/page/showcase_project_workspace — the object-master-detail-form renders the showcase_project parent form AND the inline Tasks child (the block composes parent + children, not a bare error)", + "Active Projects (type:'list', interfaceConfig): open /_console/apps/showcase_app/page/showcase_active_projects — the always-on base filterBy (status != completed) hides completed rows, the default sort (budget desc) orders them, and the grid/kanban visualization switch + health/status userFilters render", + "degradation probe (scratch page, no shipped file touched): PUT /api/v1/meta/page/qa_page_probe?mode=draft a type:'app' page with (a) an object-grid whose objectName is a NONEXISTENT object and (b) a component whose `type` is a bogus string; publish; open /_console/apps/showcase_app/page/qa_page_probe and screenshot — each bad block must surface a NAMED error (SchemaRenderer 'Unknown component type: <t>' red panel / the SchemaErrorBoundary 'Component … failed to render' panel / the block's own empty-or-error state), never a silently blank region", + "capture the server log across the whole sweep (the render-clean and no-restart baseline)" + ], + "acceptance": [ + { + "clause": "every block TYPE declared on the shipped declarative pages resolves to a real renderer — no region renders the OBJUI-001 'Unknown component type' panel or the SchemaErrorBoundary failure panel on command-center / my-work / project-workspace / active-projects; the composition the page authored is the composition on screen", + "oracle": "dom", + "verify": "after a screenshot confirms each page rendered, the region DOM contains each declared block's rendered output (a chart SVG, a grid table, a metric tile, the master-detail form) and NOT a role=alert error/'Unknown component type' panel", + "evidence": "per-page screenshot + region DOM read" + }, + { + "clause": "a data-bound block shows SEEDED rows, cross-checked against the server not pixels — command-center's object-grid rows equal GET /api/v1/data/showcase_task, and the '活跃项目 Active' object-metric equals the active-project count from the data plane", + "oracle": "api", + "verify": "the grid's visible rows and the KPI numbers each match the same query/filter run against /api/v1/data/* — a tile or grid that renders but disagrees with the data plane is caught here", + "evidence": "the data reads paired with the tile/grid screenshot" + }, + { + "clause": "object-chart buckets are data-real and multi-bucket: each chart drew a sized SVG (no width(-1)/height(-1) collapse) and its buckets match the dataset aggregation — not a single-datapoint smear that 'renders' but proves nothing", + "oracle": "api", + "verify": "the '任务状态分布' bar buckets equal the showcase_task_metrics aggregate (dimensions status, values task_count); the SVG bounding box has width>0 and height>0", + "evidence": "chart screenshot (sized SVG) + the dataset aggregate read" + }, + { + "clause": "my-work's {current_user_id} filter is SERVER-honoured: the personal grid's rows equal GET /api/v1/data/showcase_task filtered to the signed-in owner, and a task owned by another user is ABSENT — the token resolved to the real actor, not to every row", + "oracle": "api", + "verify": "the grid rows are a subset of showcase_task and equal the owner_id={signed-in id} filtered set; a foreign-owned task id is not among them", + "evidence": "the filtered data read + the grid screenshot" + }, + { + "clause": "the interface (list) page honours its interfaceConfig: active-projects hides completed rows (base filterBy status != completed), orders by budget desc, and exposes the grid/kanban switch plus the health/status user-filters — the page IS the view definition (ADR-0047), not a bare object dump", + "oracle": "screenshot", + "verify": "the rendered list shows no completed rows, biggest-budget first, with the visualization switch and userFilters controls present; cross-check the no-completed-rows claim against the data plane", + "evidence": "the list screenshot + a data read confirming completed rows exist but are filtered out" + }, + { + "clause": "object-master-detail-form composes parent + child: project-workspace renders the showcase_project create form AND the inline Tasks child affordance (grid/add-button), not just the parent or an error", + "oracle": "dom", + "verify": "after a screenshot confirms render, the DOM carries the parent field inputs and the child Tasks section (the master_detail relationship the block auto-derives from showcase_task.project)", + "evidence": "screenshot + the master-detail DOM read" + }, + { + "clause": "a page block that cannot bind degrades with a NAMED error, never a blank — the scratch page's bogus-`type` block renders 'Unknown component type', and the missing-object object-grid renders an attributable error/empty-state (the SchemaErrorBoundary or the block's own message), so a broken binding is SEEN, not swallowed", + "oracle": "screenshot", + "verify": "the qa_page_probe render shows the named error for each bad block; a silently blank region for either would be the failure this clause exists for", + "evidence": "the scratch-page screenshot" + } + ], + "negative": [ + "a data-bound region rendering EMPTY over seeded data with no error and no empty-state (silently swallowed) is a FAIL — the OBJUI-001 'Unknown component type' panel is the CORRECT behaviour for a bad block; a blank is the bug", + "a declared block silently ABSENT (dropped) while the page still 'renders clean' is a FAIL — render-clean (the smoke's bar) is necessary but not sufficient; the block must actually be composed", + "an object-chart 'rendering' as a zero-height or single-bucket smear over a multi-bucket seed is a FAIL (single-datapoint trap)" + ], + "variants": [ + "layout container: flex (command-center) / grid (my-work, component-gallery)", + "structure: page:header / page:card (with a per-user `visible` gate)", + "content: element:text / element:divider", + "data-bound: object-metric (aggregate KPI, variant bare) / object-chart (dataset-bound bar/donut/area) / object-grid (object-bound list, {current_user_id} filter) / object-form (create form) / object-master-detail-form (parent + children)" + ], + "traps": ["hydration-race", "stale-console-bundle", "seed-data-thin", "single-datapoint", "automation-input"], + "source": [ + "packages/spec/src/ui/page.zod.ts (PageComponentType enum — namespaced page:/record:/element: block types; PageComponentSchema.dataSource per-element binding; PageComponentSchema.type = union(enum, string) so objectui-registered custom blocks like object-metric/object-chart/object-grid/object-form/object-master-detail-form/flex/grid are valid)", + "examples/app-showcase/src/ui/pages/command-center.page.ts (object-metric bare KPIs + object-chart bar/donut/area on datasets + object-grid work queue), my-work.page.ts ({current_user_id} object-grid + page:card visible-gate), project-workspace.page.ts (object-master-detail-form), active-projects.page.ts (interfaceConfig list: filterBy/sort/appearance/userFilters/addRecord)", + "objectui: packages/components/src/renderers/layout/page.tsx (PageRenderer — region/template dispatch, full-bleed for width:'full' main)", + "objectui: packages/react/src/SchemaRenderer.tsx (the OBJUI-001 'Unknown component type' red panel + the SchemaErrorBoundary 'Component failed to render' panel — the NAMED-degradation contract; page.<var>/data/user expression scope)", + "objectui: e2e/live/showcase-smoke.spec.ts (the shallow render-clean sweep for my-work/project-workspace/active-projects — this item deepens it with block-composition + data-binding assertions)", + "examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts (showcase_task_metrics / showcase_project_metrics — the chart bindings)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: declarative custom pages render their real block composition and bind data (API cross-check, not pixels), with a scratch-page probe proving a bad binding degrades to a NAMED error not a blank — grounded in the four showcase declarative page sources, the PageComponentType enum, objectui PageRenderer + SchemaRenderer, and deepening the shallow showcase-smoke render-clean pin", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.page-variables-and-actions", + "title": "Page variables + page-level actions are runtime-live: variables initialize empty, an interactive writer updates one and dependent visibleWhen predicates re-evaluate WITHOUT reload, and a page action reads the live variable snapshot to POST resolved values that create a real record", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["admin (seeded admin@objectos.ai) driving the published showcase app as an end user"], + "fixtures": { + "app": "showcase", + "requires": [ + "seeded showcase_project rows (the element:record_picker on showcase_page_variables lists them)", + "the public web-to-lead endpoint POST /api/v1/forms/contact-us/submit and its showcase_inquiry sink (ADR-0056) reachable on the stock showcase boot — the contact-form submit action posts here" + ], + "knownGaps": [ + "neither showcase_page_variables nor showcase_contact_form is in objectui e2e/live/showcase-smoke.spec.ts's SURFACES sweep — there is no automated render-clean pin for either, so this item is browser-driven end to end (not marked `automated`)" + ] + }, + "steps": [ + "open /_console/apps/showcase_app/page/showcase_page_variables and screenshot the INITIAL state: the empty-hint (element:text, visibleWhen \"page.selectedProjectId == ''\") is VISIBLE and the detail panel (divider/heading/body, visibleWhen \"page.selectedProjectId != ''\") is ABSENT — the variable initialized to its empty value", + "select a project in the element:record_picker (id: project_picker — the `source` of the selectedProjectId variable) with a ref-targeted selection (not a coordinate click); screenshot again: the empty-hint disappears and the detail panel appears LIVE, with NO page reload", + "read the DOM to confirm the toggle is real, not a repaint: the detail-panel nodes are present and the empty-hint absent once the picker wrote page.selectedProjectId", + "open /_console/apps/showcase_app/page/showcase_contact_form; type into the element:text_input id=field_email (native setter + input event); the ready_hint (element:text, visibleWhen \"page.inquiryEmail != ''\") must appear the instant a value is present", + "fill all four inputs (field_name/field_email/field_company/field_message — each the `source` of its string page variable), click the submit element:button (id: submit_inquiry), and capture the network request its `api` action issues to POST /api/v1/forms/contact-us/submit", + "assert the request BODY carries the RESOLVED variable values (name/email/company/message from the typed text), not the literal {{page.inquiryName}} … tokens — the PageVariableActionBridge resolved them from the live snapshot", + "cross-check the server effect: GET /api/v1/data/showcase_inquiry — a new row carrying the entered name/email/company/message", + "negative probe: reload showcase_page_variables without picking — the detail panel stays hidden (the variable re-initialized empty, it did not retain a stale selection)" + ], + "acceptance": [ + { + "clause": "page variables initialize to their empty value and GATE dependent blocks on first render: page.selectedProjectId == '' so the empty-hint shows and the detail panel is withheld — the master/detail surface starts in its empty state, driven by the declared variable", + "oracle": "screenshot", + "verify": "the first render of showcase_page_variables shows the empty-hint and no detail panel", + "evidence": "the initial-state screenshot" + }, + { + "clause": "an interactive writer updates its variable and dependent visibleWhen predicates re-evaluate LIVE: selecting in project_picker (the `source` of selectedProjectId) reveals the detail panel and hides the hint with NO reload — the write re-runs the predicates immediately (ADR-0049 runtime-live variables)", + "oracle": "screenshot", + "verify": "before/after screenshots across a single selection: empty-hint→gone, detail panel→shown, same page load", + "evidence": "before/after screenshots" + }, + { + "clause": "the DOM reflects the toggle, not just pixels: after a screenshot confirms render, the detail-panel nodes are present and the empty-hint absent once a value is written to page.selectedProjectId", + "oracle": "dom", + "verify": "the gated nodes' presence/absence in the DOM matches the variable state (page.<var> in the SchemaRenderer expression scope)", + "evidence": "the before/after DOM reads" + }, + { + "clause": "contact-form's per-field variables bind live: typing into field_email flips the ready_hint visible (page.inquiryEmail != '') — one text input writing one page variable drives another component's visibility", + "oracle": "screenshot", + "verify": "the ready_hint appears the instant an email value is present in the input", + "evidence": "the ready-hint screenshot" + }, + { + "clause": "the page-level action dispatches with RESOLVED variables: the submit button's `api` action POSTs /api/v1/forms/contact-us/submit with params resolved from the live page-variable snapshot (PageVariableActionBridge), NOT literal {{page.<var>}} strings", + "oracle": "network", + "verify": "the captured POST body's name/email/company/message equal the typed values; no field carries an unresolved '{{page.…}}' token", + "evidence": "the submit request body" + }, + { + "clause": "the action's server effect is real: a new showcase_inquiry record carrying the entered fields exists after submit (ADR-0056 web-to-lead) — the page action moved data, it did not just toast", + "oracle": "api", + "verify": "GET /api/v1/data/showcase_inquiry returns a row with the submitted name/email/company/message", + "evidence": "the data read of the new inquiry" + } + ], + "negative": [ + "a detail/ready block visible BEFORE any interaction (the variable was not initialized empty) is a FAIL — the empty state is the declared initial condition", + "a variable write that needs a page RELOAD to take effect is a FAIL — the whole point of runtime-live variables (ADR-0049) is immediate re-evaluation of dependent predicates", + "a submit that POSTs the literal {{page.inquiryName}} token (the bridge did not resolve the snapshot) is a FAIL", + "a 2xx submit that creates NO showcase_inquiry (the action reported success but moved nothing) is a FAIL — server truth is the oracle, not the toast" + ], + "variants": [ + "writer: element:record_picker → record_id variable (page-variables)", + "writer: element:text_input → string variable (contact-form)", + "reader: component visibleWhen `page.<var>` (empty-state / detail gating)", + "reader: page action params `{{page.<var>}}` (submit POST body)" + ], + "traps": ["hydration-race", "stale-console-bundle", "automation-input"], + "source": [ + "packages/spec/src/ui/page.zod.ts (PageVariableSchema — `source` is the WRITER component id, read by predicates as page.<name>; PageSchema.variables runtime-live per ADR-0049; PageComponentSchema.visibleWhen binds record/current_user/page.<var>, ADR-0089)", + "examples/app-showcase/src/ui/pages/page-variables.page.ts (selectedProjectId ← project_picker; empty-hint vs detail gated on page.selectedProjectId), contact-form.page.ts (four string vars ← text_inputs; submit `api` action posting {{page.<var>}} to /api/v1/forms/contact-us/submit → showcase_inquiry)", + "objectui: packages/components/src/renderers/layout/page.tsx (PageVariablesProvider mounts the declared variables; PageVariableActionBridge publishes the live snapshot into the action runtime so a submit resolves {{page.<var>}})", + "objectui: packages/react/src/SchemaRenderer.tsx (page.<var> threaded into the ExpressionEvaluator scope; visibleWhen/visibility re-evaluated on variable change)", + "ADR-0056 (public web-to-lead form → showcase_inquiry), ADR-0089 (visibleWhen canonical predicate)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: the end-to-end page-variable loop (initialize empty → interactive writer updates → dependent visibleWhen re-evaluates live, no reload) and the page-level `api` action resolving {{page.<var>}} from the live snapshot to create a real showcase_inquiry — grounded in PageVariableSchema, the page-variables + contact-form sources, and objectui's PageVariablesProvider/PageVariableActionBridge", "ref": "claude/platform-test-checklist-ocwugl" } + ] + }, + { + "id": "studio-authoring.custom-page-source-tiers", + "title": "Source-authored page tiers render by the RIGHT path: kind:'html' source is PARSED-never-executed (static, in-browser sdui-parser compile), kind:'react' source is EXECUTED behind its capability gate (live data + interactivity) — command-center-jsx / capability-map (html) vs crm-workbench (react)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["admin (seeded admin@objectos.ai) driving the published showcase app as an end user"], + "fixtures": { + "app": "showcase", + "requires": [ + "seeded showcase_project rows (the react page's live KPI + <ListView> read them)", + "the react-pages capability ON — CAP_REACT_PAGES defaults ON; a boot with OS_PAGE_REACT=off flips it OFF (the both-sides gate check)" + ], + "knownGaps": [ + "NO manual build step is needed to VIEW either tier in the running showcase (contra the task's guess): a kind:'html' page compiles IN-BROWSER via @object-ui/sdui-parser compile(source, manifest) at render, against a whitelist manifest built from the LIVE ComponentRegistry (packages/components/src/renderers/layout/page.tsx); a kind:'react' page is transpiled IN-BROWSER by a lazily-loaded Sucrase @object-ui/react-runtime chunk. The only build-shaped risk is the vendored /_console bundle being stale (stale-console-bundle) — a stale sdui-parser/react-runtime mis-compiles a page that is fine on a fresh build.", + "none of showcase_command_center_jsx / showcase_capability_map / showcase_crm_workbench is in objectui e2e/live/showcase-smoke.spec.ts's SURFACES sweep — there is no automated render-clean pin for the html/react tiers, so this item is browser-driven (not marked `automated`)." + ] + }, + "steps": [ + "capability-map (kind:'html', isDefault landing): open /_console/apps/showcase_app/page/showcase_capability_map; screenshot the six protocol-domain cards; confirm the compiled SchemaNode tree rendered (flex/div/a nodes with theme-token style objects), NOT the 'HTML page failed to compile' panel", + "command-center-jsx (kind:'html'): open /_console/apps/showcase_app/page/showcase_command_center_jsx; screenshot the KPI board (Open Tasks 128 / In Progress 47 / Completed 1,902 / Cycle Time 2.4d) and the weekly-throughput bars", + "prove the html tier is PARSED-NEVER-EXECUTED and STATIC: the KPI numbers are hand-authored sample copy — 128 does NOT equal GET /api/v1/data/showcase_task's count, and NO data query fires for those tiles (the page's own on-screen copy says the numbers are static sample copy)", + "crm-workbench (kind:'react'): open /_console/apps/showcase_app/page/showcase_crm_workbench; screenshot the workbench — the KPI strip (Total / Active) + the real <ListView> and <ObjectForm> master/detail over showcase_project", + "prove the react tier is EXECUTED against LIVE data: the 'Total projects' KPI equals GET /api/v1/data/showcase_project's count (the page computes it via useAdapter().find('showcase_project'), reading the QueryResult .data envelope), and a showcase_project find query fires on the network — the number is computed at render, not authored", + "prove react interactivity (executed handlers, not a parsed tree): click a ListView row → the ObjectForm binds THAT record (React useState); click '+ New project' → the form switches to create mode", + "both-sides gate: on a boot with OS_PAGE_REACT=off, re-open crm-workbench — it must render the NAMED 'React pages are disabled on this deployment' notice (CapabilityDisabledNotice), never a blank and never an executed page; the html pages still render (html is not gated). If the env toggle cannot be flipped on the stock fixture, record this clause blocked(environment)", + "degradation: confirm an html compile error would surface the 'HTML page failed to compile (N)' panel with the diagnostic messages, and a react runtime error the 'React page error' fallback — neither a blank page (drive via the scratch draft door if a shipped page cannot be made to fault)" + ], + "acceptance": [ + { + "clause": "the renderer dispatches on `kind`: an html page renders the sdui-parser-COMPILED tree (parsed, never executed) via SchemaRenderer, and a react page renders through ReactKindPage — evidenced by the distinct surfaces each produces (html = static composed board; react = live data + interactivity)", + "oracle": "screenshot", + "verify": "capability-map/command-center-jsx render composed static boards; crm-workbench renders a data-driven interactive workbench — the two paths produce their characteristic surfaces", + "evidence": "the three page screenshots" + }, + { + "clause": "the html tier is STATIC and UNEXECUTED: command-center-jsx's KPI numbers are hand-authored sample copy — they do NOT equal the data-plane counts, and NO data query fires for them (parse-never-execute, ADR-0080)", + "oracle": "api", + "verify": "the on-screen KPI (e.g. Open Tasks 128) differs from GET /api/v1/data/showcase_task's count, and the network shows no find/aggregate request backing those tiles", + "evidence": "the KPI screenshot + the data read (mismatch is the point) + the network trace showing no data query" + }, + { + "clause": "the react tier is EXECUTED against live data: crm-workbench's 'Total projects' KPI equals GET /api/v1/data/showcase_project's count and a showcase_project find query fires — the value is computed at render by author JS (useAdapter().find), not authored (ADR-0081)", + "oracle": "api", + "verify": "the KPI number equals the live showcase_project count, and the network carries the find query the page's useEffect issues", + "evidence": "the KPI screenshot + the data read (match) + the find-query network trace" + }, + { + "clause": "react HANDLERS run (execution, not a parsed tree): selecting a ListView row binds the ObjectForm to that record and '+ New project' switches the form to create mode — React state transitions the html tier could never express", + "oracle": "dom", + "verify": "after a screenshot confirms render, a ref-targeted row click updates the form to the selected record; the New button switches modes", + "evidence": "before/after screenshots + the form DOM read" + }, + { + "clause": "the react capability gate is real and BOTH-sided: with CAP_REACT_PAGES on, crm-workbench executes; with OS_PAGE_REACT=off it renders the NAMED 'React pages are disabled on this deployment' notice — never a blank, never a bypassed execution (the html pages are unaffected)", + "oracle": "screenshot", + "verify": "the on-state executes; the off-state shows CapabilityDisabledNotice with the OS_PAGE_REACT=off text; record blocked(environment) if the toggle cannot be exercised on the stock fixture", + "evidence": "the on-state + off-state screenshots (or the blocked-environment note)" + }, + { + "clause": "compile/runtime failures degrade NAMED, never blank: an html compile error is the 'HTML page failed to compile (N)' panel listing the diagnostics; a react error is the 'React page error' fallback carrying the message", + "oracle": "screenshot", + "verify": "a faulted html source shows the compile-error panel; a faulted react source shows the error fallback — a blank page for either is the failure this clause exists for", + "evidence": "the error-panel screenshot(s)" + } + ], + "negative": [ + "an html page whose source is EXECUTED (author JS runs) rather than parsed is a security FAIL — ADR-0080's html tier is parse-never-execute; execution is the react tier's gated privilege only", + "a react page that executes with OS_PAGE_REACT=off (the gate bypassed) is a FAIL — the capability is the whole safety boundary for the trusted-execution tier", + "a react KPI stuck at 0 while the <ListView> beside it shows rows is the crm-workbench QueryResult-envelope bug regressed (reading .records instead of .data) — FAIL, cross-checked against the live count", + "a compile or runtime error swallowed to a blank page (no named panel) is a FAIL — the author must SEE why the page did not render" + ], + "variants": [ + "html tier: command-center-jsx (composition of registered components with structured props) + capability-map (domain cards + out-links, isDefault landing)", + "react tier: crm-workbench (useAdapter + <ListView>/<ObjectForm> + React useState master/detail)" + ], + "traps": ["stale-console-bundle", "hydration-race", "single-datapoint", "automation-input"], + "source": [ + "packages/spec/src/ui/page.zod.ts (PageSchema.kind = full|slotted|html|react|jsx; html = constrained JSX/HTML+Tailwind compiled by @objectstack/sdui-parser 'parse, never execute' ADR-0080; react = real React executed at render, gated by a host capability defaulting ON, disabled via OS_PAGE_REACT=off, ADR-0081; the superRefine that fails an html/react page with no `source`)", + "objectui: packages/components/src/renderers/layout/page.tsx (PageRenderer kind dispatch — compile(src, getJsxManifest()) + SchemaRenderer for html with the 'HTML page failed to compile' panel; ReactKindPage for react; the manifest whitelist is built from the LIVE registry's known types)", + "objectui: packages/components/src/renderers/layout/react-page.tsx (ReactKindPage — CAP_REACT_PAGES gate + CapabilityDisabledNotice for OS_PAGE_REACT=off; lazy Sucrase @object-ui/react-runtime transpile; useAdapter/ListView/ObjectForm scope; the 'React page error' fallback)", + "objectui: packages/sdui-parser/src/index.ts (compile(source, manifest) — the pure in-browser parse used at render, no build step)", + "examples/app-showcase/src/ui/pages/command-center-jsx.page.ts (kind:'html', static sample KPIs — 'parsed, never executed'), capability-map.page.ts (kind:'html' landing), crm-workbench.page.ts (kind:'react' — useAdapter().find reads the .data envelope, note the #… KPIs-stuck-at-0 fix)", + "objectui: content/docs/guide/react-pages.md (react tier: transpiled + evaluated in-app, react-pages capability, OS_PAGE_REACT=off disable)" + ], + "history": [ + { "revision": 1, "date": "2026-08-08", "change": "new item: the html (parse-never-execute, in-browser sdui-parser compile, static) vs react (executed at render, capability-gated, live data + interactivity) source-page tiers — the objectui renderer-path split grounded in PageRenderer's kind dispatch + react-page.tsx's gate, distinguishing command-center-jsx/capability-map (html) from crm-workbench (react); records the honest build-step finding (none needed to view — in-browser compile/transpile) as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" } + ] + } + ] +} diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json new file mode 100644 index 0000000000..f039596adb --- /dev/null +++ b/docs/qa/platform-checklist/coverage.json @@ -0,0 +1,190 @@ +{ + "$comment": "Capability-coverage ratchet for the platform test checklist. The universe of governed metadata kinds is DERIVED at check time from packages/spec/liveness/*.json (the ADR-0049 ledger set) — this file must map EVERY kind to at least one checklist item, or waive it with a reason. scripts/check-platform-checklist.mjs flags both directions: an unmapped kind (the platform grew a capability the checklist doesn't test) and an entry for a kind with no liveness ledger (orphan, mirrors the liveness ORPHAN discipline). NOTE: this check runs on a MANUAL/periodic cadence (`pnpm check:platform-checklist`), not in per-PR CI — see docs/qa/platform-checklist/README.md 'Operating cadence'. Pattern copied from examples/app-showcase/src/coverage.ts (demonstrated-or-waived, ADR-0060 house ledger style).", + "metadataKinds": { + "action": { + "items": [ + "records-forms.action-param-widgets", + "ai.mcp-run-action-exposure-gate", + "records-forms.action-location-matrix" + ] + }, + "agent": { + "items": [ + "ai.agent-tool-skill-metadata-roundtrip", + "ai.open-edition-honest-degradation" + ] + }, + "api": { + "items": [ + "api-backend.declarative-endpoint-execution" + ] + }, + "app": { + "items": [ + "platform-core.boot-health", + "platform-core.nav-surfaces-render" + ] + }, + "book": { + "waived": "docs-shaped content kind (display-only, ADR-0033 exemption class). The console DOES ship a docs/book reader (objectui apps/console DocPage/BookPage) — but the kind is authored-content-as-data with no independent runtime behavior to gate beyond serving; parse coverage exists via the spec's own tests. Waived for behavior, not for lack of a surface." + }, + "dashboard": { + "items": [ + "dashboards.strict-widget-rejects-stray-keys", + "dashboards.chart-type-matrix", + "dashboards.global-filters-rescope", + "dashboards.chart-first-paint", + "dashboards.empty-null-bucket-boundaries" + ] + }, + "dataset": { + "items": [ + "dashboards.dataset-report-authoring" + ] + }, + "datasource": { + "items": [ + "integration-system.external-datasource-federated-read" + ] + }, + "doc": { + "waived": "docs-shaped content kind (display-only, ADR-0033 exemption class) — same posture as `book`: a reader surface exists in the console, but there is no runtime behavior to assert beyond serving." + }, + "email_template": { + "items": [ + "integration-system.email-template-render" + ] + }, + "field": { + "items": [ + "records-forms.field-type-matrix", + "records-forms.conditional-rules-header", + "records-forms.cascading-options", + "records-forms.lookup-picker-create-new" + ] + }, + "flow": { + "items": [ + "automation.flow-node-type-matrix", + "automation.trigger-type-matrix", + "automation.flow-error-handling", + "automation.screen-flow-roundtrip", + "automation.durable-suspend-restart", + "automation.flow-runs-page-test-trigger", + "automation.flow-toggle-kill-switch" + ] + }, + "hook": { + "items": [ + "records-forms.object-hook-lifecycle" + ] + }, + "job": { + "items": [ + "integration-system.job-scheduled-run" + ] + }, + "mapping": { + "items": [ + "records-forms.named-import-mapping" + ] + }, + "object": { + "items": [ + "records-forms.crud-roundtrip", + "platform-core.metadata-authoring-roundtrip", + "studio-authoring.object-designer-roundtrip" + ] + }, + "page": { + "items": [ + "studio-authoring.record-page-roundtrip", + "platform-core.nav-surfaces-render" + ] + }, + "permission": { + "items": [ + "access-security.crud-permission-matrix", + "access-security.owd-sharing-matrix", + "access-security.fls-mask-and-strip", + "access-security.rls-both-sides", + "access-security.sharing-rules-widen", + "access-security.record-share-grant-revoke", + "access-security.permission-matrix-edit-loop", + "access-security.sharing-rule-authoring-ui", + "access-security.owd-save-gate", + "access-security.share-link-capability-tokens" + ] + }, + "position": { + "items": [ + "access-security.scope-depth-asymmetry", + "approvals.per-group-signoff", + "approvals.dynamic-approver-routing", + "identity-auth.teams-bu-membership" + ] + }, + "query": { + "items": [ + "api-backend.query-contract-matrix" + ] + }, + "report": { + "items": [ + "dashboards.dataset-report-authoring", + "dashboards.drill-through-range", + "dashboards.saved-report-ownership" + ] + }, + "seed": { + "items": [ + "platform-core.seed-integrity" + ] + }, + "skill": { + "items": [ + "ai.agent-tool-skill-metadata-roundtrip", + "ai.skill-instructions-mcp-prompts" + ] + }, + "tool": { + "items": [ + "ai.agent-tool-skill-metadata-roundtrip" + ] + }, + "translation": { + "items": [ + "i18n.surface-matrix", + "i18n.strict-translation-key-rejection", + "i18n.build-gates-hold" + ] + }, + "validation": { + "items": [ + "access-security.write-path-guards", + "records-forms.conditional-rules-header", + "records-forms.validation-rule-type-matrix" + ] + }, + "view": { + "items": [ + "records-forms.view-type-gallery", + "records-forms.form-view-gallery", + "records-forms.list-view-capabilities", + "studio-authoring.view-authoring-live", + "records-forms.gantt-interactions", + "records-forms.saved-view-management" + ] + }, + "webhook": { + "items": [ + "integration-system.webhook-lifecycle" + ] + }, + "capability": { + "items": [ + "access-security.capability-declaration-lifecycle" + ] + } + } +} \ No newline at end of file diff --git a/docs/qa/platform-checklist/runs/.gitignore b/docs/qa/platform-checklist/runs/.gitignore new file mode 100644 index 0000000000..14ae3f9a35 --- /dev/null +++ b/docs/qa/platform-checklist/runs/.gitignore @@ -0,0 +1,12 @@ +# Test RESULTS do not live in the repo. A run record and its evidence are +# artifacts of one execution against one build — they are output, not source, +# and committing them would turn a version-controlled contract into a dumping +# ground of dated PNGs. Only this directory's README (the record FORMAT) and +# this .gitignore are tracked. +# +# Where results go instead: the executing environment (CI artifact, the runner's +# workspace) or the sweep's tracking issue / an external QA store. The checklist +# in areas/ is the durable source; a run is a transient assertion about a build. +* +!.gitignore +!README.md diff --git a/docs/qa/platform-checklist/runs/README.md b/docs/qa/platform-checklist/runs/README.md new file mode 100644 index 0000000000..8179f683ea --- /dev/null +++ b/docs/qa/platform-checklist/runs/README.md @@ -0,0 +1,40 @@ +# Run records — format contract (results are NOT committed) + +A **run record** is one execution of the checklist against one build: per-clause +verdicts + evidence pointers. It is **output, not source** — a transient assertion +about a specific build, not part of the version-controlled contract. Run records and +their evidence (screenshots, network traces) are therefore **git-ignored** and never +land in the repo (`.gitignore` here tracks only this README). The durable source is +the checklist itself under `../areas/`; a run is a snapshot that goes stale the moment +the build moves. + +**Where results go instead:** the executing environment — a CI artifact, the runner's +own workspace, the sweep's tracking issue, or an external QA store. Keep them there; +do not commit them. + +## Record shape (write to `YYYY-MM-DD-<slug>.json`, kept out of git) + +The shape and the verdict rules are defined in [../RUNNER.md](../RUNNER.md); verdicts +are only meaningful next to the item `revision` they ran against. + +```jsonc +{ + "run": "2026-08-07-v17-release-sweep", + "date": "2026-08-07", + "scope": "since:v17 + P0", // the filter that selected items + "app": "showcase", + "env": { "framework": "<sha>", "objectuiPin": "<sha>", "port": 3456, "db": "file:/tmp/<run>/data.db" }, + "runner": "<agent/session identifier>", + "evidenceDir": "<local path — not committed>", + "results": [ + { + "id": "approvals.per-group-signoff", + "revision": 1, // ← the revision this verdict is valid for + "verdict": "pass", // derived: pass | partial | fail | blocked | not-run + "clauses": [ { "clause": 0, "verdict": "pass", "evidence": "…what was captured, where…" } ], + "issues": [], + "notes": "…" + } + ] +} +``` diff --git a/package.json b/package.json index 13868f7b4a..462d6d1894 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs", "check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs", "check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs", + "check:platform-checklist": "node scripts/checklist-select.mjs --self-test && node scripts/check-platform-checklist.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs", "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs", "check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs", diff --git a/scripts/check-platform-checklist.mjs b/scripts/check-platform-checklist.mjs new file mode 100644 index 0000000000..b5bacb0412 --- /dev/null +++ b/scripts/check-platform-checklist.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-platform-checklist — keep the standing platform test checklist +// (docs/qa/platform-checklist/) machine-readable, append-only and honest. +// +// ## The failure this exists for +// +// Release verification used to live in one-off surfaces: a hand-written table +// per release (docs/plans/release-15.1-test-plan.md) and a checkbox issue per +// release (#3358). Both worked once and then rotted: items could not be reused +// across releases, results were checkboxes with no revision to pin them to, and +// every fixture gap the run discovered (#3408 phone persona never seeded, #3409 +// per-group sign-off never launched, #3415 four of five projects silently +// rejected by seed validation) had to be rediscovered from prose. The standing +// checklist replaces those one-offs with a durable ledger; this gate keeps the +// ledger's invariants from decaying the same way. +// +// ## What this checks (deliberately dumb, presence-level — house ledger style) +// +// - every docs/qa/platform-checklist/areas/*.json parses and its `area` +// matches its filename; +// - every item carries the required fields with sane enum values; +// - ids are `<area>.<slug>`, globally unique, and — append-only discipline — +// never removed: ids retired from service stay in the file with +// `status: "retired"` (this check cannot see deletions; the README makes +// removal a review-time offence, and `supersededBy` targets must resolve); +// - `revision` matches the last `history` entry, so a semantic edit that +// forgets to bump the revision (silently re-validating old run results) +// fails here; +// - active items have at least one acceptance clause, and every clause names +// its oracle — a clause with no oracle is an invitation to tick on vibes, +// which is the exact AI-accuracy failure the RUNNER.md protocol exists to +// prevent. +// +// It does NOT judge whether an item is testable or its oracle sufficient — no +// static check can. It guarantees the *structure* a run can be trusted against. +// +// Usage: node scripts/check-platform-checklist.mjs (pnpm check:platform-checklist) + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +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 STATUSES = new Set(['active', 'draft', 'retired']); +const PRIORITIES = new Set(['P0', 'P1', 'P2']); +const SURFACES = new Set(['browser', 'api', 'cli', 'build', 'mixed']); +const ORACLES = new Set(['api', 'network', 'screenshot', 'dom', 'log', 'test', 'build']); +const BLOCKED_BY = new Set(['fixture', 'environment', 'dependency', 'product-bug']); + +const errors = []; +const err = (file, id, msg) => errors.push(`${file}${id ? ` · ${id}` : ''}: ${msg}`); + +if (!existsSync(AREAS_DIR)) { + console.error(`check-platform-checklist: missing ${AREAS_DIR}`); + process.exit(1); +} + +const files = readdirSync(AREAS_DIR).filter((f) => f.endsWith('.json')).sort(); +if (files.length === 0) { + console.error('check-platform-checklist: no area files found — the ledger cannot be empty.'); + process.exit(1); +} + +const allIds = new Map(); // id -> file +const allItems = []; + +for (const file of files) { + let doc; + try { + doc = JSON.parse(readFileSync(join(AREAS_DIR, file), 'utf8')); + } catch (e) { + err(file, null, `does not parse as JSON: ${e.message}`); + continue; + } + const stem = basename(file, '.json'); + if (doc.area !== stem) err(file, null, `"area" is ${JSON.stringify(doc.area)} but the filename says "${stem}"`); + if (typeof doc.title !== 'string' || !doc.title) err(file, null, 'missing "title"'); + if (!Array.isArray(doc.items) || doc.items.length === 0) { + err(file, null, '"items" must be a non-empty array'); + continue; + } + + for (const item of doc.items) { + const id = typeof item.id === 'string' ? item.id : '<no id>'; + const where = (msg) => err(file, id, msg); + + if (!/^[a-z0-9-]+\.[a-z0-9-]+$/.test(id)) where('id must be "<area>.<slug>" in kebab-case'); + else if (!id.startsWith(`${doc.area}.`)) where(`id must be prefixed with its own area ("${doc.area}.")`); + if (allIds.has(id)) where(`duplicate id — already defined in ${allIds.get(id)}; ids are immutable and never reused`); + allIds.set(id, file); + allItems.push({ file, item }); + + if (typeof item.title !== 'string' || !item.title) where('missing "title"'); + if (!STATUSES.has(item.status)) where(`"status" must be one of ${[...STATUSES].join('|')}`); + if (!PRIORITIES.has(item.priority)) where(`"priority" must be one of ${[...PRIORITIES].join('|')}`); + if (!SURFACES.has(item.surface)) where(`"surface" must be one of ${[...SURFACES].join('|')}`); + if (typeof item.since !== 'string' || !/^v\d+(\.\d+)?$/.test(item.since)) { + where('"since" must be the release that introduced the capability, e.g. "v16" or "v16.0"'); + } + + if (!Number.isInteger(item.revision) || item.revision < 1) where('"revision" must be an integer >= 1'); + if (!Array.isArray(item.history) || item.history.length === 0) { + where('"history" must be a non-empty array — every item records why it exists'); + } else { + const last = item.history[item.history.length - 1]; + if (last.revision !== item.revision) { + where(`"revision" (${item.revision}) must equal the last history entry's revision (${last.revision}) — a semantic edit bumps both`); + } + for (const h of item.history) { + if (!Number.isInteger(h.revision) || typeof h.date !== 'string' || typeof h.change !== 'string') { + where('each history entry needs { revision, date, change }'); + break; + } + } + } + + if (!Array.isArray(item.steps) || item.steps.length === 0) where('"steps" must be a non-empty array of strings'); + + if (item.status === 'retired') { + if (typeof item.retiredReason !== 'string' || !item.retiredReason) where('retired items must carry "retiredReason"'); + } else { + if (!Array.isArray(item.acceptance) || item.acceptance.length === 0) { + where('active/draft items must have at least one acceptance clause'); + } else { + item.acceptance.forEach((c, i) => { + if (typeof c.clause !== 'string' || !c.clause) where(`acceptance[${i}] missing "clause"`); + if (!ORACLES.has(c.oracle)) where(`acceptance[${i}] "oracle" must be one of ${[...ORACLES].join('|')}`); + if (typeof c.verify !== 'string' || !c.verify) where(`acceptance[${i}] missing "verify" — how the oracle is consulted`); + }); + } + } + + if (item.blocked !== undefined) { + if (!BLOCKED_BY.has(item.blocked?.by)) where(`"blocked.by" must be one of ${[...BLOCKED_BY].join('|')}`); + if (typeof item.blocked?.ref !== 'string' || !item.blocked.ref) { + where('"blocked.ref" must name the tracking issue/fixture gap — waive-with-a-reference, never silently'); + } + } + + if (item.automated !== undefined && item.automated !== null) { + if (typeof item.automated.ref !== 'string' || !item.automated.ref) where('"automated.ref" must point at the pinning test'); + } + + if (item.enumSource !== undefined) { + const es = item.enumSource; + if (typeof es?.file !== 'string' || typeof es?.export !== 'string' || !Number.isInteger(es?.expect)) { + where('"enumSource" needs { file, export, expect } — the spec enum this item\'s variants matrix was authored against'); + } + } + } +} + +// ── Variants-freshness ratchet ────────────────────────────────────────────── +// A matrix item's `variants` list is hand-authored against a spec value enum +// (49 field types, 20 chart types, …). When the spec grows or shrinks that +// enum, nothing used to force the matrix to follow — the drift was only caught +// indirectly by the showcase coverage.test.ts demonstrability gate. `enumSource` +// pins the enum here: {file, export, expect}. This check extracts the CURRENT +// member count from the spec source (comment-stripped, deduped — enum blocks +// carry prose comments quoting member names) and fails when it no longer equals +// `expect`. Fixing the failure = revising the item's variants for the new +// member(s), bumping the item revision, and updating `expect` — exactly the +// "platform grew a capability, the checklist must follow" moment this gate +// exists to force. Extractor rot is loud, not fail-open: a missing file or +// export is an error, never a silent skip. +function extractEnumMembers(absFile, exportName) { + const src = readFileSync(absFile, 'utf8'); + const decl = src.match(new RegExp(`(?:export\\s+)?const\\s+${exportName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b[^=]*=`)); + if (!decl) return null; + const start = src.indexOf('[', decl.index + decl[0].length); + if (start === -1) return null; + let depth = 0; + for (let j = start; j < src.length; j++) { + if (src[j] === '[') depth++; + else if (src[j] === ']') { + depth--; + if (depth === 0) { + const seg = src.slice(start, j + 1) + .replace(/\/\/[^\n]*/g, '') + .replace(/\/\*[\s\S]*?\*\//g, ''); + const seen = new Set(); + for (const m of seg.matchAll(/'([a-zA-Z0-9_\-]+)'/g)) seen.add(m[1]); + return [...seen]; + } + } + } + return null; +} + +for (const { file, item } of allItems) { + const es = item.enumSource; + if (!es || typeof es.file !== 'string' || typeof es.export !== 'string') continue; + const abs = join(ROOT, es.file); + if (!existsSync(abs)) { + err(file, item.id, `enumSource.file not found: ${es.file} — the pinned spec source moved; re-point the pin`); + continue; + } + const members = extractEnumMembers(abs, es.export); + if (members === null) { + err(file, item.id, `enumSource export "${es.export}" not found in ${es.file} — renamed or reshaped; re-point the pin (extractor must stay loud, never fail-open)`); + continue; + } + if (members.length !== es.expect) { + err(file, item.id, `VARIANTS STALE — ${es.export} in ${es.file} now has ${members.length} members but this item's variants were authored against ${es.expect}. The platform grew/shrank this surface: revise the variants matrix, bump the item revision, and set enumSource.expect to ${members.length}.`); + } +} + +// Cross-file referential integrity: supersededBy must land on a real id. +for (const { file, item } of allItems) { + if (item.supersededBy !== undefined && !allIds.has(item.supersededBy)) { + err(file, item.id, `"supersededBy" points at unknown id "${item.supersededBy}"`); + } +} + +// ── Capability-coverage ratchet ───────────────────────────────────────────── +// "凡是有的能力, 都要测试" made mechanical: the universe of governed metadata +// kinds is derived from packages/spec/liveness/*.json (the ADR-0049 ledger +// set), and coverage.json must map every kind to ≥1 checklist item or waive it +// with a reason. Bidirectional, mirroring the liveness ledger's own +// UNCLASSIFIED/ORPHAN discipline: an unmapped kind fails (the platform grew a +// capability the checklist doesn't test), and a mapped kind with no liveness +// ledger fails (the entry outlived the capability). +const COVERAGE_FILE = join(ROOT, 'docs/qa/platform-checklist/coverage.json'); +const LIVENESS_DIR = join(ROOT, 'packages/spec/liveness'); +let waivedCount = 0; +let mappedCount = 0; +if (!existsSync(COVERAGE_FILE)) { + err('coverage.json', null, 'missing — every liveness-governed metadata kind must be mapped or waived'); +} else if (!existsSync(LIVENESS_DIR)) { + err('coverage.json', null, `cannot derive the kind universe: ${LIVENESS_DIR} not found`); +} else { + let cov; + try { + cov = JSON.parse(readFileSync(COVERAGE_FILE, 'utf8')); + } catch (e) { + cov = null; + err('coverage.json', null, `does not parse as JSON: ${e.message}`); + } + if (cov) { + const universe = readdirSync(LIVENESS_DIR) + .filter((f) => f.endsWith('.json')) + .map((f) => basename(f, '.json')) + .sort(); + const map = cov.metadataKinds ?? {}; + for (const kind of universe) { + const entry = map[kind]; + if (entry === undefined) { + err('coverage.json', kind, 'UNCLASSIFIED — the platform has this capability (liveness ledger exists) but the checklist neither tests nor waives it. Add items or a waiver with a reason.'); + continue; + } + const hasItems = Array.isArray(entry.items) && entry.items.length > 0; + const hasWaiver = typeof entry.waived === 'string' && entry.waived.trim().length > 0; + if (hasItems === hasWaiver) { + err('coverage.json', kind, 'must have EITHER non-empty "items" OR a non-empty "waived" reason — not both, not neither'); + continue; + } + if (hasItems) { + mappedCount++; + for (const id of entry.items) { + if (!allIds.has(id)) err('coverage.json', kind, `maps to unknown item id "${id}"`); + else { + const mapped = allItems.find((r) => r.item.id === id); + if (mapped?.item.status === 'retired') { + err('coverage.json', kind, `maps to retired item "${id}" — point at its successor or re-waive the kind`); + } + } + } + } else { + waivedCount++; + } + } + for (const kind of Object.keys(map)) { + if (!universe.includes(kind)) { + err('coverage.json', kind, `ORPHAN — mapped kind has no packages/spec/liveness/${kind}.json ledger; remove the entry or restore the ledger`); + } + } + } +} + +if (errors.length) { + console.error(`check-platform-checklist: ${errors.length} problem(s)\n`); + for (const e of errors) console.error(` ✗ ${e}`); + console.error('\nContract: docs/qa/platform-checklist/README.md (authoring) · RUNNER.md (execution).'); + process.exit(1); +} + +const total = allItems.length; +const active = allItems.filter(({ item }) => item.status === 'active').length; +console.log(`check-platform-checklist: OK — ${files.length} areas, ${total} items (${active} active); coverage: ${mappedCount} kinds mapped, ${waivedCount} waived.`); diff --git a/scripts/checklist-select.mjs b/scripts/checklist-select.mjs new file mode 100644 index 0000000000..11d702a984 --- /dev/null +++ b/scripts/checklist-select.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// checklist-select — resolve a SELECTOR into the concrete set of platform-checklist +// items to run. The deterministic front half of the `checklist-run` skill: the skill +// drives a browser, this script decides WHAT to drive, with zero LLM guesswork. +// +// node scripts/checklist-select.mjs <selector> [--json] [--include-blocked] +// node scripts/checklist-select.mjs --self-test +// +// ## Selectors (one per invocation) +// +// <area>.<slug> an exact item id e.g. platform-core.console-login +// item:<id> same, explicit +// area:<area> every item in an area e.g. area:records-forms +// <area> bare area name = area:<area> e.g. approvals +// capability:<kind> items mapped to a metadata kind in coverage.json e.g. capability:hook +// priority:P0|P1|P2 every item at that priority +// surface:browser|api|... every item on that execution surface +// since:vN every item introduced in release vN (prefix match: since:v16 ⊇ v16.0) +// file:<path> ★ items whose `source[]` cites this framework file (or its +// basename / containing dir) — "test whatever covers this file" +// all every active item +// +// Blocked items (carrying `blocked:{by,ref}`) are EXCLUDED by default — they cannot run +// on stock fixtures. Pass --include-blocked to list them too (the runner records them as +// blocked with their fixture reason, per RUNNER.md). +// +// Output: a table (id · priority · surface · blocked?) to stderr for humans, and — with +// --json — a machine list to stdout for the runner to fan out over. + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, basename, dirname } from 'node:path'; + +const ROOT = new URL('..', import.meta.url).pathname; +const AREAS_DIR = join(ROOT, 'docs/qa/platform-checklist/areas'); +const COVERAGE = join(ROOT, 'docs/qa/platform-checklist/coverage.json'); + +/** Load every item once, tagged with its area. */ +function loadItems(areasDir = AREAS_DIR) { + const items = []; + for (const f of readdirSync(areasDir).filter((f) => f.endsWith('.json')).sort()) { + const doc = JSON.parse(readFileSync(join(areasDir, f), 'utf8')); + for (const it of doc.items || []) items.push(it); + } + return items; +} + +/** + * Resolve a selector string against a set of items (+ optional coverage map). + * Pure and side-effect-free so the self-test can exercise it directly. + * @returns {object[]} the matched items (order: as declared) + */ +export function selectItems(selector, items, coverage = { metadataKinds: {} }) { + const active = items.filter((it) => it.status === 'active'); + const byId = (id) => active.filter((it) => it.id === id); + + if (selector === 'all') return active; + + const [rawKey, ...rest] = selector.includes(':') ? selector.split(':') : [null, selector]; + const key = rawKey; // null when the selector had no prefix + const val = rest.join(':'); // rejoin so file:path/with:colons survives (rare) + + if (key === 'item') return byId(val); + if (key === 'area') return active.filter((it) => it.id.startsWith(`${val}.`)); + if (key === 'capability') { + const mapped = new Set((coverage.metadataKinds?.[val]?.items) || []); + return active.filter((it) => mapped.has(it.id)); + } + if (key === 'priority') return active.filter((it) => it.priority === val); + if (key === 'surface') return active.filter((it) => it.surface === val); + if (key === 'since') return active.filter((it) => it.since === val || (it.since || '').startsWith(`${val}.`)); + if (key === 'file') { + const p = val.replace(/^\.?\//, ''); + const base = basename(p); + const dir = dirname(p); + // Narrowest-useful precedence: prefer items citing the exact path or its + // basename ("test whatever covers THIS file"); only when nothing cites the + // file directly fall back to a directory-level match (so `file:<a-dir>` + // still resolves the items covering that area of the tree). + const exact = active.filter((it) => (it.source || []).some((s) => s.includes(p) || s.includes(base))); + if (exact.length) return exact; + if (dir !== '.') return active.filter((it) => (it.source || []).some((s) => s.includes(dir))); + return []; + } + + // No recognized prefix → treat the whole string as an id, else as an area name. + if (key === null) { + const asId = byId(val); + if (asId.length) return asId; + return active.filter((it) => it.id.startsWith(`${val}.`)); + } + return []; // unknown prefix +} + +function isBlocked(it) { + return it.blocked !== undefined; +} + +// ── self-test ──────────────────────────────────────────────────────────────── +if (process.argv.includes('--self-test')) { + const FIX = [ + { id: 'a.one', status: 'active', priority: 'P0', surface: 'browser', since: 'v16', source: ['packages/foo/bar.ts'] }, + { id: 'a.two', status: 'active', priority: 'P1', surface: 'api', since: 'v16.1', source: ['#3358'], blocked: { by: 'fixture', ref: '#1' } }, + { id: 'b.three', status: 'active', priority: 'P0', surface: 'api', since: 'v15', source: ['packages/foo/baz.ts'] }, + { id: 'b.gone', status: 'retired', priority: 'P0', surface: 'api', since: 'v15', retiredReason: 'x' }, + ]; + const COV = { metadataKinds: { hook: { items: ['a.one'] } } }; + const ids = (sel) => selectItems(sel, FIX, COV).map((i) => i.id).sort(); + const eq = (got, want, name) => { + const g = JSON.stringify(got), w = JSON.stringify(want); + if (g !== w) { console.error(`✗ ${name}: got ${g}, want ${w}`); process.exit(1); } + }; + eq(ids('all'), ['a.one', 'a.two', 'b.three'], 'all excludes retired'); + eq(ids('a.one'), ['a.one'], 'bare id'); + eq(ids('item:a.one'), ['a.one'], 'item: prefix'); + eq(ids('a'), ['a.one', 'a.two'], 'bare area'); + eq(ids('area:b'), ['b.three'], 'area: prefix'); + eq(ids('capability:hook'), ['a.one'], 'capability via coverage'); + eq(ids('priority:P0'), ['a.one', 'b.three'], 'priority'); + eq(ids('surface:api'), ['a.two', 'b.three'], 'surface'); + eq(ids('since:v16'), ['a.one', 'a.two'], 'since prefix (v16 ⊇ v16.1)'); + eq(ids('file:packages/foo/bar.ts'), ['a.one'], 'file exact'); + eq(ids('file:foo'), ['a.one', 'b.three'], 'file dir match'); + eq(ids('nope.xxx'), [], 'unknown id → empty'); + console.log('✓ checklist-select self-test: 12 cases pass.'); + process.exit(0); +} + +// ── CLI ────────────────────────────────────────────────────────────────────── +const args = process.argv.slice(2); +const json = args.includes('--json'); +const includeBlocked = args.includes('--include-blocked'); +const selector = args.find((a) => !a.startsWith('--')); + +if (!selector) { + console.error('usage: node scripts/checklist-select.mjs <selector> [--json] [--include-blocked]'); + console.error(' selectors: <id> | area:<a> | capability:<k> | priority:P0 | surface:api | since:vN | file:<path> | all'); + process.exit(2); +} +if (!existsSync(AREAS_DIR)) { + console.error(`checklist-select: ${AREAS_DIR} not found`); + process.exit(1); +} + +const items = loadItems(); +const coverage = existsSync(COVERAGE) ? JSON.parse(readFileSync(COVERAGE, 'utf8')) : { metadataKinds: {} }; +let matched = selectItems(selector, items, coverage); +const droppedBlocked = includeBlocked ? [] : matched.filter(isBlocked); +if (!includeBlocked) matched = matched.filter((it) => !isBlocked(it)); + +if (json) { + process.stdout.write(JSON.stringify(matched.map((it) => ({ id: it.id, priority: it.priority, surface: it.surface, since: it.since, revision: it.revision })), null, 2) + '\n'); +} + +console.error(`\nselector: ${selector} → ${matched.length} runnable item(s)${droppedBlocked.length ? ` (${droppedBlocked.length} blocked, hidden — pass --include-blocked)` : ''}\n`); +for (const it of matched) { + console.error(` ${it.priority} ${String(it.surface).padEnd(8)} ${it.id}${isBlocked(it) ? ' [BLOCKED]' : ''}`); +} +if (droppedBlocked.length) { + console.error(`\n hidden (blocked): ${droppedBlocked.map((i) => i.id).join(', ')}`); +} +if (matched.length === 0) { + console.error(' (nothing matched — check the selector; try `all` or `area:<name>`)'); + process.exit(1); +}