diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 2036d0ec68f..3bf271d3ba8 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -132,7 +132,7 @@ "title": "Write-path guards: readonly strip, owner_id forge/transfer denied, bulk validation", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P0", "surface": "api", "personas": [ @@ -152,9 +152,9 @@ "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" + "as Alice: POST /api/v1/data/showcase_private_note/createMany with a BARE JSON ARRAY of 3 records carrying no owner_id (e.g. [{\"title\": \"bulk 1\"}, …]) — the body is the array itself; a {\"records\": […]} wrapper answers 400 VALIDATION_FAILED ('the body must be a JSON array of record objects'); 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). updateMany is id-based: the body is {\"records\": [{\"id\": \"\", \"data\": {…}}, …]} — a {where, data} body answers 400 VALIDATION_FAILED naming `records`", + "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 {\"records\": [{\"id\", \"data\"}, …], \"options\": {\"continueOnError\": true}}; read the per-row outcome. ⚠️ Without options.continueOnError the bulk run stops at the FIRST failure (the BatchOptionsSchema default): rows before it are applied, the failing row carries its own error and every later row reports errors[0].code NOT_ATTEMPTED — so put a compliant row AFTER the violating one and send continueOnError true for the mixed-outcome leg; the default run is the stop-at-first-failure shape, also worth one capture" ], "acceptance": [ { @@ -196,7 +196,7 @@ { "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", + "verify": "updateMany response distinguishes per-row results; re-reads confirm compliant rows changed and violating rows did not — measured with options.continueOnError true (rows before and after the violating one applied, the violating row VALIDATION_FAILED and unchanged); the default stop (later rows NOT_ATTEMPTED and unchanged) is a different shape and does not satisfy this clause on its own", "evidence": "bulk response + row reads" } ], @@ -243,6 +243,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21720 parked on #22017 (row 12). N2 was not runnable: there is no REST door for an in-process isSystem write, and no pin targeted it. Disposition: pin. engine-insert-static-readonly-strip.test.ts and engine-readonly-strip-caller-values.test.ts already prove the isSystem exemption on a real engine, on INSERT and UPDATE. `automated` names them for negative[1], and the negative says to score it from them", "ref": "#22017" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "wire shapes the steps left out (#23878 checklist-accuracy findings, re-measured on main c91af8ed6). createMany takes a bare JSON array (a {records} wrapper answers 400 VALIDATION_FAILED); updateMany takes {records:[{id,data}]} (a where body answers 400 naming records); and a bulk run stops at the first validation failure by default, marking later rows NOT_ATTEMPTED, so clause 7's mixed-outcome step now sends options.continueOnError true. Measured on showcase_invoice_line [valid, quantity -1, valid]: default → succeeded 1, failed 2 (VALIDATION_FAILED, NOT_ATTEMPTED), only row 0 changed; continueOnError → succeeded 2, failed 1, rows 0 and 2 changed, row 1 unchanged", + "ref": "#24036" } ] }, @@ -342,7 +348,7 @@ "title": "A read-only package actually locks Studio editing surfaces", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "mixed", "personas": [ @@ -359,7 +365,7 @@ "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", - "POST /api/v1/packages to create a scratch writable base package, author an object into it (PUT /api/v1/meta/object/?package=), then repeat the same trivial-change PUT against that object to prove the guard discriminates by package writability, not by blanket denial. ⛔ Do not duplicate com.example.showcase: POST /packages/com.example.showcase/duplicate answers 422 DUPLICATE_SOURCE_NOT_A_BASE, because the source must be a base (#14451)" + "POST /api/v1/packages to create a scratch writable base package — the manifest needs a `type` (e.g. {\"manifest\": {\"id\": \"com.example.qa-base\", \"name\": \"QA Base\", \"version\": \"1.0.0\", \"scope\": \"project\", \"type\": \"app\"}}; without it the install door answers 400 VALIDATION_ERROR naming manifest.type, and scope must be one of cloud | system | project), author an object into it (PUT /api/v1/meta/object/_?package=, the namespace being the one the install echoes back) — the object needs a `sharingModel` (e.g. 'private'), or the save answers 422 INVALID_METADATA with rule security-owd-unset — then repeat the same trivial-change PUT against that object to prove the guard discriminates by package writability, not by blanket denial. ⛔ Do not duplicate com.example.showcase: POST /packages/com.example.showcase/duplicate answers 422 DUPLICATE_SOURCE_NOT_A_BASE, because the source must be a base (#14451)" ], "acceptance": [ { @@ -425,6 +431,12 @@ "date": "2026-10-05", "change": "clause 3, step 5, the fixture line and clause 2's code set re-pointed (stale, #21784 VF4; the lane had it as an assertion defect, and the verifier's disposition is used). POST /packages/com.example.showcase/duplicate answers 422 DUPLICATE_SOURCE_NOT_A_BASE: requireDuplicableSource (runtime/src/domains/packages.ts, #14451) refuses a code-package source. This predates 17.6.0 (and 17.5.0), and it supersedes the duplicate route revision 3 adopted under the #9788 ruling. The clause uses the verifier's wording: the writable contrast is a scratch base created through POST /api/v1/packages. Clause 2's code set adds NOT_OVERRIDABLE, the bare PUT's answer, per the run's FE7 (ITEM_LOCKED only with ?package=)", "ref": "#21797" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "step 5 names the two shapes the writable contrast needs (#23878 checklist-accuracy finding, re-measured on main c91af8ed6): POST /api/v1/packages answers 400 VALIDATION_ERROR ('manifest.type: Invalid option …') without a manifest `type` and 201 with type 'app' (scope project; the install echoes a derived namespace), and an object authored into the scratch base answers 422 INVALID_METADATA, rule security-owd-unset, without a sharingModel and 200 with one. The forged write on the packaged object still answers 403 (NOT_OVERRIDABLE bare, ITEM_LOCKED with ?package=com.example.showcase) and leaves its document byte-identical", + "ref": "#24036" } ] }, @@ -433,7 +445,7 @@ "title": "CRUD × permission-set matrix: every access-matrix.json row holds — allowed verbs succeed, withheld verbs 403, VAMA bounded", "since": "v15", "status": "active", - "revision": 5, + "revision": 6, "priority": "P0", "surface": "api", "personas": [ @@ -444,15 +456,14 @@ "fixtures": { "app": "showcase", "requires": [ - "examples/app-showcase/access-matrix.json — it IS the expectation table (47 permission-set × object rows at revision 4: 41 showcase_* rows plus 6 showcase_field_ops_delegate × sys_* rows; recount from the file each run)", + "examples/app-showcase/access-matrix.json — it IS the expectation table (45 permission-set × object rows, measured on main c91af8ed6: 41 showcase_* rows plus 4 showcase_field_ops_delegate × sys_* rows — sys_business_unit, sys_business_unit_member, sys_user, sys_user_position; the two delegate rows on the security-catalog objects left with #23222; recount from the file each run)", "seeded permission sets showcase_contributor/manager/executive/auditor/ops/member_default/guest_portal/field_ops_delegate/client_liaison (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. Measured on the committed table: 9 of the 164 showcase_* cells (41 rows × 4 verbs at revision 4) are granted ONLY by the baseline (showcase_auditor and showcase_executive can CREATE an inquiry and create+edit a private note; guest_portal can READ an inquiry; showcase_manager and showcase_ops can CREATE an inquiry). showcase-crud-persona-matrix.dogfood.test.ts computes the union and asserts those 9 as allowed.", "access-matrix.json is the APP's declared matrix; the platform's own baseline permission sets are additionally in force and grant some sys_* surface the app matrix never mentions (measured: a plain member reads /data/sys_user 200, self-scoped). So the absence of a row is NOT a prediction of denial on sys_* objects, and the automated sweep judges the showcase_* rows only — see automated.ref.", "clause 5 (guest_portal create-without-read) is UNOBSERVABLE on a stock boot — record it blocked(fixture), never pass or fail. The guest anchor ships unbound (binding guest → showcase_guest_portal is an admin action; the app only ships the capability, permission-sets.ts GuestPortalPermissionSet), a member persona holding the set also holds the baseline's read on showcase_inquiry, and QA run #21056 measured REST anonymous create AND read both answering 401 even with the anchor bound. The only anonymous create lane is the public form (publicFormGrant on the inquiry form view, ADR-0056 Option A), whose grant is create plus read-back of the row it created — a different contract from this clause's", - "sys_user create and delete answer 405 OBJECT_API_METHOD_NOT_ALLOWED for EVERY caller, admin included: sys_user declares apiMethods ['get','list','update','bulk'] (identity writes belong to better-auth, ADR-0092), and the API-exposure gate answers before any permission is judged. Score those two delegate × sys_user cells against 405, not 403", - "A1 (acceptance[0], every allowed cell succeeds) is a KNOWN FAIL on 17.x until the v18 ADR-0131 C1 business-unit leg lands (#21057, target:v18, gated by #15193): the seeded sys_business_unit rows carry organization_id null, so the showcase_field_ops_delegate × sys_user_position in-subtree create (bu_west_coast under bu_field_ops) answers 403 PERMISSION_DENIED \"outside the delegated subtree\" and the delegate's scope resolves to an empty subtree (the gate fails closed: over-refusal, never exposure). Measured discriminator (#21057 comments 5927366808 and 5979438043): once the organization is stamped on the seeded units, the in-subtree create flips to 201 while the out-of-subtree create (bu_hq_finance) stays 403 — so the cause is the unstamped seed, not the gate. Score A1 FAIL in the run table and read it as known (#21057), not new; do NOT re-derive it, do NOT weaken the clause, do NOT stamp the units to make it pass (that would hide the very gap this entry tracks). It turns green when the v18 C1 leg lands — that flip is the signal the gap closes, and this entry is then removed (ruling: #21057 comment 5980557328, ADR-0131 D3/D14)" + "sys_user create and delete answer 405 OBJECT_API_METHOD_NOT_ALLOWED for EVERY caller, admin included: sys_user declares apiMethods ['get','list','update','bulk'] (identity writes belong to better-auth, ADR-0092), and the API-exposure gate answers before any permission is judged. Score those two delegate × sys_user cells against 405, not 403" ] }, "steps": [ @@ -462,7 +473,7 @@ "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): record clause 5 blocked(fixture) per knownGaps — a member holding the set reads inquiries through the baseline, and REST anonymous answers 401 to both verbs", - "for the 6 showcase_field_ops_delegate × sys_* rows, drive the delegate by hand; for its sys_user_position edit and delete cells, probe a row the DELEGATE created — a row another principal created meets the platform's write ownership floor (member_default owner_only_writes / owner_only_deletes, created_by == current_user.id), so its verdict does not read the delegate's grant", + "for the 4 showcase_field_ops_delegate × sys_* rows, drive the delegate by hand; for its sys_user_position edit and delete cells, probe a row the DELEGATE created — a row another principal created meets the platform's write ownership floor (member_default owner_only_writes / owner_only_deletes, created_by == current_user.id), so its verdict does not read the delegate's grant", "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": [ @@ -505,7 +516,7 @@ { "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 (47 rows × 4 verbs at revision 4: the 41 showcase_* rows are 164 cells, 87 effective-allow and 77 effective-deny after the baseline union; recount from the file each run)", + "verify": "verdict matrix dimensions match access-matrix.json entries (45 rows × 4 verbs at revision 6: the 41 showcase_* rows are 164 cells, 87 effective-allow and 77 effective-deny after the baseline union; recount from the file each run)", "evidence": "the matrix artifact" } ], @@ -526,7 +537,7 @@ ], "automated": { "kind": "verify", - "ref": "packages/verify/src/verify.ts (runCrudVerification) + packages/verify/src/rls.ts (runRlsProofs) — `objectstack verify --rls` for the OBJECT-level CRUD and cross-owner RLS half (the flag is load-bearing: bare verify prints no RLS section) + packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts for the PERSONA-grained cells: every showcase_* row of access-matrix.json (41 at revision 4) driven over all four verbs by one fresh member per permission set — 164 cells, 87 effective-allow and 77 effective-deny, each judged against the BASELINE-UNIONED expectation (row OR showcase_member_default row, ADR-0090 D5), which is also asserted as load-bearing on the 9 cells whose only grant is the baseline. Reaches clauses 0, 1, 2, 3, 4 and 6 for those rows; every denial is checked to be 403 PERMISSION_DENIED specifically, every allow to persist, and an admin payload control runs first so a persona 403 cannot be a 400 in disguise. STILL MANUAL: (a) the 6 showcase_field_ops_delegate x sys_* rows — the platform's OWN baseline sets grant part of that surface independently of the app matrix (measured: a member holding nothing but the app baseline reads /data/sys_user 200, self-scoped, though the matrix lists sys_user read only for the delegate), and the delegate's sys_user_position writes are decided by the ADR-0090 D12 adminScope subtree gate that showcase-permission-zoo.dogfood.test.ts pins on both sides ON THE VERIFY HARNESS — a raw cell verdict there would be false in one direction or the other. ⚠️ That pin does not vouch for a dev boot: QA run #21056 measured the same in-subtree assignment answering 403 'outside the delegated subtree' on a stock dev boot (verifier-confirmed product defect, tracked at #21057), so drive those cells by hand and score them against the live answer; (b) clause 5's guest-anchor intake asymmetry (guest_portal create-without-read) — NOT observable on a member persona at all, because the everyone baseline grants read on showcase_inquiry and capability is a union, so the member-persona expectation is READ ALLOWED; the asymmetry lives on the unauthenticated guest anchor, a different admission lane." + "ref": "packages/verify/src/verify.ts (runCrudVerification) + packages/verify/src/rls.ts (runRlsProofs) — `objectstack verify --rls` for the OBJECT-level CRUD and cross-owner RLS half (the flag is load-bearing: bare verify prints no RLS section) + packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts for the PERSONA-grained cells: every showcase_* row of access-matrix.json (41 at revision 4) driven over all four verbs by one fresh member per permission set — 164 cells, 87 effective-allow and 77 effective-deny, each judged against the BASELINE-UNIONED expectation (row OR showcase_member_default row, ADR-0090 D5), which is also asserted as load-bearing on the 9 cells whose only grant is the baseline. Reaches clauses 0, 1, 2, 3, 4 and 6 for those rows; every denial is checked to be 403 PERMISSION_DENIED specifically, every allow to persist, and an admin payload control runs first so a persona 403 cannot be a 400 in disguise. STILL MANUAL: (a) the 4 showcase_field_ops_delegate x sys_* rows — the platform's OWN baseline sets grant part of that surface independently of the app matrix (measured: a member holding nothing but the app baseline reads /data/sys_user 200, self-scoped, though the matrix lists sys_user read only for the delegate), and the delegate's sys_user_position writes are decided by the ADR-0090 D12 adminScope subtree gate that showcase-permission-zoo.dogfood.test.ts pins on both sides ON THE VERIFY HARNESS — a raw cell verdict there would be false in one direction or the other. That pin runs on the verify harness, so drive those cells by hand on the dev boot and score them against the live answer: since #21057 closed (2026-10-08) the seeded business units carry organization_id and the delegate's in-subtree sys_user_position create answers 201 while the out-of-subtree create (bu_hq_finance) answers 403 PERMISSION_DENIED 'outside the delegated subtree'; (b) clause 5's guest-anchor intake asymmetry (guest_portal create-without-read) — NOT observable on a member persona at all, because the everyone baseline grants read on showcase_inquiry and capability is a union, so the member-persona expectation is READ ALLOWED; the asymmetry lives on the unauthenticated guest anchor, a different admission lane." }, "traps": [ "wrong-persona", @@ -563,6 +574,12 @@ "date": "2026-10-04", "change": "knownGaps records that A1 is a known FAIL on 17.x: #21057 (seeded business units carry no organization, so the field-ops delegate's in-subtree create is refused) is ruled to wait for ADR-0131 C1's business-unit leg in v18 (target:v18, gated by #15193). The clause, the priority and every other item are unchanged; A1 stays a FAIL in the run table and goes green when the v18 leg lands", "ref": "#21747 (maintainer ruling: #21057 comment 5980557328)" + }, + { + "revision": 6, + "date": "2026-10-16", + "change": "counts and the known gap re-measured on main c91af8ed6 (#23878 checklist-accuracy findings). access-matrix.json now has 45 rows: 41 showcase_* plus 4 delegate × sys_* (sys_business_unit, sys_business_unit_member, sys_user, sys_user_position), so the fixture line, steps[6], acceptance[6] and automated.ref say 45 / 4 instead of 47 / 6; the 164 showcase cells and their 87 allow / 77 deny / 9 baseline-only split are unchanged. knownGaps[4] (A1 a KNOWN FAIL until #21057) is removed, as its own ruling prescribed once the C1 business-unit leg landed: #21057 closed on 2026-10-08, the five seeded sys_business_unit rows carry organization_id, and the delegate's in-subtree create answers 201 while bu_hq_finance stays 403. A1 is therefore expected to pass; a 403 on the in-subtree create is now a fail, not a known one", + "ref": "#24036" } ] }, @@ -692,7 +709,7 @@ "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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -717,7 +734,7 @@ "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", + "in the console as the contributor: open the project's detail/edit form; screenshot; inspect the state of the budget and spent controls — the two budget figures the edit form renders (project.view.ts form section 'Budget & Schedule': budget, spent, start_date, end_date). budget_remaining is a formula field and is not on the edit form at all", "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" ], @@ -741,9 +758,9 @@ "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", + "clause": "the UI renders the FLS state faithfully for the contributor: the budget figures the edit form carries (budget and spent) are visible (readable:true) but not editable — input disabled/read-only on the edit form. budget_remaining is a formula: it is never on the edit form and never editable for anyone, so it cannot discriminate the FLS lock and is not scored here", "oracle": "dom", - "verify": "after a screenshot confirms the form rendered, assert the disabled/read-only state of the three budget controls", + "verify": "after a screenshot confirms the form rendered, assert the disabled/read-only state of the budget and spent controls (the form section names exactly those two of the three figures, examples/app-showcase/src/ui/views/project.view.ts); an EDITABLE budget or spent input for the contributor is the fail", "evidence": "screenshot + DOM read" }, { @@ -788,6 +805,12 @@ "date": "2026-08-18", "change": "the SEED gap is closed too: showcase_client_liaison is a stock permission set carrying readable:false on all three showcase_project budget figures, so clause 5's UI half is reachable for the first time — a real member, a real console render, no test-local stack. knownGaps rewritten as closed-by-fixture rather than deleted, with the merge caveat (do not also grant showcase_contributor) and the reason all three figures move together. The #8993 maskingRule gap is UNTOUCHED and still has no fixture anywhere in this repo", "ref": "#9308" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "clause 4 and step 5 asked for the disabled state of THREE budget controls (#23878 checklist-accuracy finding). The edit form renders two: project.view.ts's 'Budget & Schedule' section lists budget, spent, start_date and end_date, and budget_remaining is a formula field that no edit form carries (re-measured on main c91af8ed6). The clause now scores budget and spent, and says why the formula cannot discriminate the lock. The API clauses are unchanged", + "ref": "#24036" } ] }, @@ -1014,7 +1037,7 @@ "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, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -1026,25 +1049,26 @@ "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)" + "a runtime member added to the Field Operations business-unit subtree — as admin POST /api/v1/data/sys_business_unit_member {\"user_id\": \"\", \"business_unit_id\": \"bu_west_coast\"} (a descendant of bu_field_ops) — as the unit audience, a member outside that subtree as the outsider, and a manager-position member only to record the position variant's depth confound (knownGaps)" ], "knownGaps": [ - "the owner-based rule is [experimental] and skipped per the liveness ledger — exclude it; criteria + position/unit audiences are the live surface" + "the owner-based rule is [experimental] and skipped per the liveness ledger — exclude it; criteria + position/unit audiences are the live surface", + "the POSITION-audience variant is CONFOUNDED on stock and scores no widening clause. The only seeded position rule, share_key_account_qualified_contacts_with_managers, shares Northwind-qualified showcase_contact rows with the manager position, and manager's showcase_manager set reads showcase_contact at readScope 'org'. Measured on main c91af8ed6: a manager-holding member reads all 32 contacts, and POST /api/v1/security/explain answers visible, decidedBy 'depth' on a matching AND a non-matching contact — the sharing layer never decides. So acceptance[0]-[2] are scored on the unit_and_subordinates variant (share_new_inquiries_with_field_ops: a member of bu_field_ops or a descendant unit reads exactly the status='new' inquiry, and its sys_record_share row carries source 'rule'); the position variant records its depth-decided reads as the confound. A position audience without org depth is authored in-run by access-security.sharing-rule-authoring-ui" ] }, "variants": [ - "criteria rule, position audience", + "criteria rule, position audience (confounded on stock by showcase_manager's org-depth read — see knownGaps; recorded, not scored as widening)", "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", + "boot showcase isolated; add the audience member to bu_west_coast through sys_business_unit_member (fixtures.requires); give a second member the manager position to record the position confound", + "the probes are seeded: share_new_inquiries_with_field_ops matches status='new', and the seed ships one new, one contacted and one closed showcase_inquiry (OWD private). Use the new one as the MATCHING probe and a non-new one as the NON-MATCHING probe", "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)" + "manual-share the non-matching inquiry to a specific user via the record-shares route (POST /api/v1/data/showcase_inquiry//shares); that user re-reads, and a third member re-reads too. The recipient needs object-level read on the object: a share on showcase_contact to a baseline member lands 201 but the member's read answers 403 at the object gate, because the member baseline grants no showcase_contact read — so a manual share is only observable on an object the recipient can already read at object level", + "§A13: as admin edit the SEEDED sharing-rule row (PATCH /api/v1/data/sys_sharing_rule/, e.g. its description), restart the server on the same database, re-read the row (seed-not-clobber). The materialized share is not editable: PATCH /api/v1/data/sys_record_share/ answers 405 OBJECT_API_METHOD_NOT_ALLOWED (allowed get, list, aggregate, search, export)" ], "acceptance": [ { @@ -1072,9 +1096,9 @@ "evidence": "the two reads" }, { - "clause": "§A13 — an admin edit to a materialized/seeded share survives a restart (the seed does not clobber it, #2909)", + "clause": "§A13 — an admin edit to a SEEDED sharing-rule row (sys_sharing_rule) survives a restart: the declared-rules bootstrap does not clobber it (#2909). sys_record_share refuses edits (405), so the rule row is the editable seeded artifact", "oracle": "api", - "verify": "post-restart re-read shows the admin's edit intact", + "verify": "post-restart re-read of the sys_sharing_rule row shows the admin's edit intact", "evidence": "before/after-restart reads" } ], @@ -1097,6 +1121,12 @@ "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" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "re-pointed on main c91af8ed6 (#23878 checklist-accuracy findings). (1) The position-audience variant is confounded on stock: the manager position binds showcase_manager, which reads showcase_contact at readScope 'org', so a manager reads all 32 contacts and explain answers decidedBy 'depth' on matching and non-matching rows alike. A knownGap records it, the variant is marked recorded-not-scored, and the widening clauses are scored on the unit_and_subordinates rule (measured: a member added to bu_west_coast reads exactly the one status='new' inquiry, rule-sourced share row). Steps 1-2 name the provisioning and the seeded probes instead of the retired red-project example. (2) Step 7 / clause 5 (§A13) said 'edit a materialized share', but sys_record_share answers 405 to PATCH; the editable seeded artifact is the sys_sharing_rule row. (3) Step 6 records that a manual-share recipient needs object-level read (a contact share to a baseline member reads 403)", + "ref": "#24036" } ] }, @@ -1105,7 +1135,7 @@ "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": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": [ @@ -1189,7 +1219,7 @@ ], "source": [ "packages/rest/src/rest-route-ledger.ts#REST_ROUTE_LEDGER (record-shares family: GET/POST /data/:object/:id/shares, DELETE .../:shareId; sharing-rules family evaluate — re-pointed #18104: shareId is a route PATH PARAMETER, carried in this file only inside a route string)", - "packages/rest/src/sharing-routes.ts#registerSharingRuleEndpoints (the evaluate leg; the record-shares family GET/POST /data/:object/:id/shares + DELETE .../:shareId is registered inline, under no registrar of its own — #13788 measured that the registerRecordShareEndpoints this citation used to name is not in the file)", + "packages/rest/src/sharing-routes.ts#registerSharingEndpoints (the record-shares family: GET and POST /data/:object/:id/shares and DELETE .../:shareId, the body of RestServer#registerSharingEndpoints moved into this module whole) + packages/rest/src/sharing-routes.ts#registerSharingRuleEndpoints (the evaluate leg). The registerRecordShareEndpoints an older revision named still does not exist (#13788); the family's registrar is registerSharingEndpoints", "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts#recipient_type (recipient_type/recipient_id/access_level/source fields)", "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#evaluateRule (evaluateRule → SharingRuleEvaluationResult)", "examples/app-showcase/src/security/sharing-rules.ts#share_key_account_qualified_contacts_with_managers (share_key_account_qualified_contacts_with_managers seeded criteria rule)", @@ -1219,6 +1249,12 @@ "date": "2026-08-20", "change": "steps only — the revoke-scope probe was ordered AFTER the real revoke, which left clause 6's own evidence requirement ('the share still exists (re-read)') unsatisfiable for anyone following the steps literally: the shareId is already gone, so the refusal observed is the ordinary missing-share 404 and says nothing about record scoping. The probe now runs BEFORE the revoke, and the note-creation step creates the second record it needs. No acceptance clause changed. Found by the FIRST execution of this item, at revision 3", "ref": "#9702" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "source[1] re-pointed (carried reading of the #23878 wave, confirmed on main c91af8ed6): it said the record-shares family is 'registered inline, under no registrar of its own'. packages/rest/src/sharing-routes.ts exports registerSharingEndpoints, which registers GET and POST /data/:object/:id/shares and DELETE .../:shareId; the evaluate leg stays on registerSharingRuleEndpoints. Anchor and wording only: no step, fixture or oracle changed, so no re-run is owed", + "ref": "#24036" } ] }, @@ -1227,7 +1263,7 @@ "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": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -1254,10 +1290,10 @@ ], "acceptance": [ { - "clause": "all three ops produce audit rows with the correct action, actor and target: login→action 'login' attributed to the member; settings write→action 'config_change' — both SERVED on the data API; delete -> action 'delete' (showcase_task + record_id) is served to a reader holding view_all_audit_log (platform admins by default, #21296) and withheld from a non-holder who cannot read the deleted record (#21194).", + "clause": "all three ops produce audit rows with the correct action, actor and target: login→action 'login' attributed to the member; settings write→action 'config_change' — both SERVED on the data API; delete -> action 'delete' (showcase_task + record_id) is served to a reader holding view_all_audit_log (platform admins by default, #21296) and never reaches a non-holder: on stock a fresh member is refused the whole object (403 PERMISSION_DENIED — no stock non-admin set grants read on sys_audit_log), and a ledger reader without view_all_audit_log is held to rows about records it can read (#21194, the parent-record gate).", "oracle": "api", - "verify": "GET /api/v1/data/sys_audit_log as the admin returns the login, config_change AND delete rows, each with action / actor (user_id) / object_name / record_id matching what the op did (fields per packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The admin holds view_all_audit_log through ADMIN_FULL_ACCESS_CAPABILITIES, which exempts the holder from the ledger's parent-record read gate. The same $filter action='delete' read by a non-holder who cannot read the deleted record (e.g. the fresh member) returns NO row. A delete row withheld from the admin, or served to the non-holder, is the FAIL", - "evidence": "the admin's three served rows + the non-holder's empty delete-filtered read" + "verify": "GET /api/v1/data/sys_audit_log as the admin returns the login, config_change AND delete rows, each with action / actor (user_id) / object_name / record_id matching what the op did (fields per packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The admin holds view_all_audit_log through ADMIN_FULL_ACCESS_CAPABILITIES, which exempts the holder from the ledger's parent-record read gate. The same $filter action='delete' read by the fresh member answers 403 PERMISSION_DENIED at the object gate (measured on main c91af8ed6; the member never reaches the parent-record gate, because nothing grants it read on sys_audit_log), so no row reaches it. The parent-record gate itself — a ledger reader WITHOUT view_all_audit_log sees only rows about records it can read — has no stock non-admin holder of a sys_audit_log read and is scored from packages/plugins/plugin-audit/src/audit-log-read-visibility.test.ts and audit-log-read-visibility.integration.test.ts. A delete row withheld from the admin, or any row served to the fresh member, is the FAIL", + "evidence": "the admin's three served rows + the fresh member's 403 on the delete-filtered read + the two pin runs for the parent-record gate" }, { "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", @@ -1313,7 +1349,8 @@ }, { "revision": 2, "date": "2026-08-18", "change": "re-pointed clause 2 and step 5 at controls that exist. The item named an 'Action filter' dropdown; this console offers prebuilt filtered views (Recent / Writes / Auth / Config) plus a generic Filter Records / Where / Add filter builder. Server-side narrowing — the property the clause is actually for — is provable through the Writes view; naming a control that does not exist invites the absence-inference trap (#9453 CF-9)", "ref": "#9386" }, { "revision": 3, "date": "2026-10-02", "change": "re-pointed clause 1 (and the clauses that counted its three rows) at 17.6.0's ledger read rule. 30c530e5 (#21194) serves a non-system reader, administrators included, only audit rows about records it can read, so the delete row is written but never served on the data API; the 17.6.0 release-verification run measured the admin's delete-filtered read at 0 rows while the stored row carried correct attribution. The clause now asserts the login and config_change rows served, the delete row stored and NOT served; step 6's drawer check moves to an update row, since no delete row reaches the page", "ref": "#21330" }, - { "revision": 4, "date": "2026-10-05", "change": "clauses 1, 3 and 5 and steps 5-6 re-pointed (stale after 17.6.0, #21784 VF4 + FE7). 7ebb543164 (#21296, fixes #21260, 2026-10-02) gives platform admins view_all_audit_log by default (ADMIN_FULL_ACCESS_CAPABILITIES). That exempts them from the ledger's parent-record read gate, so the admin's read serves the delete row. Revision 3 was measured on 17.6.0 and encodes only #21194. Clauses 1 and 5 use the verifier's wording: the delete row is served to a view_all_audit_log holder and withheld from a non-holder who cannot read the deleted record. FE7: revision 2's 'there is no Action dropdown' is false at the console pin, because AuditLogPage has an Action select that narrows through $filter. Clause 3 and step 5 now name it beside the object list's prebuilt views. Re-read at objectui 9dfaca6543: unchanged", "ref": "#21797" } + { "revision": 4, "date": "2026-10-05", "change": "clauses 1, 3 and 5 and steps 5-6 re-pointed (stale after 17.6.0, #21784 VF4 + FE7). 7ebb543164 (#21296, fixes #21260, 2026-10-02) gives platform admins view_all_audit_log by default (ADMIN_FULL_ACCESS_CAPABILITIES). That exempts them from the ledger's parent-record read gate, so the admin's read serves the delete row. Revision 3 was measured on 17.6.0 and encodes only #21194. Clauses 1 and 5 use the verifier's wording: the delete row is served to a view_all_audit_log holder and withheld from a non-holder who cannot read the deleted record. FE7: revision 2's 'there is no Action dropdown' is false at the console pin, because AuditLogPage has an Action select that narrows through $filter. Clause 3 and step 5 now name it beside the object list's prebuilt views. Re-read at objectui 9dfaca6543: unchanged", "ref": "#21797" }, + { "revision": 5, "date": "2026-10-16", "change": "clause 1's non-holder arm re-pointed to the measured answer (#23878 checklist-accuracy finding, re-measured on main c91af8ed6): a fresh member is refused sys_audit_log at the object level (GET /api/v1/data/sys_audit_log and the action='delete' filter both answer 403 PERMISSION_DENIED), so the 'empty filtered list' the clause expected never appears. No stock non-admin permission set grants read on sys_audit_log, so the parent-record gate (#21194) has no live non-admin reader; the clause now names the plugin-audit visibility pins as its oracle for that gate. The clause still fails if the fresh member is served any row", "ref": "#24036" } ] }, { @@ -1416,7 +1453,7 @@ "title": "Anonymous public form renders the whitelist only and its submit strips forged anchors; the inquiry lands via publicFormGrant", "since": "v15.1", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -1438,7 +1475,7 @@ "anon POST /api/v1/forms/contact-us/submit with the whitelist fields (name/email/company/message) PLUS forged owner_id + organization_id + created_by + a non-whitelisted business field status:'closed' — capture the 201", "as admin/staff: read the landed showcase_inquiry and inspect owner_id, organization_id, created_by, status, source vs the payload", "anon GET /api/v1/forms/contact-us/lookup/owner_id — the non-whitelisted lookup picker probe", - "browser: open the public link '/forms/contact-us', submit the form, and screenshot the submitBehavior thank-you panel", + "browser: open the public link '/forms/contact-us' — on a boot that serves the console it answers 302 to the console's PUBLIC form page /_console/f/contact-us (packages/cli/src/utils/console.ts, console-route-ledger 'GET /forms/:slug'; on a console-less boot it is a plain 404) — submit the form, and screenshot the submitBehavior thank-you panel. ⛔ Not /_console/forms/:name: that is the AUTHED internal form route, which sends an anonymous visitor to the login page", "unpublish the contact form view (or clear sharing.allowAnonymous) and anon GET /api/v1/forms/contact-us again; then republish and re-GET", "smuggle probe: anon POST with a JSON body carrying a __proto__ key nesting owner_id — confirm no inherited anchor lands" ], @@ -1450,7 +1487,7 @@ "evidence": "the resolved form body" }, { - "clause": "forged anchors are stripped on submit: the landed showcase_inquiry's owner_id is the server-stamped owner (NOT the forged usr_victim), organization_id/created_by are not the forged values — a visitor cannot forge ownership (#3004/#3022)", + "clause": "forged anchors are stripped on submit: the landed showcase_inquiry's owner_id is NOT the forged usr_victim — an anonymous submit has no principal to stamp, so it lands null — organization_id is the deployment's own organization (server-stamped) and created_by is not the forged value (null) — a visitor cannot forge ownership (#3004/#3022)", "oracle": "api", "verify": "the staff read of the landed row shows owner_id/organization_id/created_by != the forged payload values", "evidence": "the landed row read + the forged payload" @@ -1514,6 +1551,12 @@ "date": "2026-10-01", "change": "the lookup-refusal clause becomes the route's absence: ruling E retired the anonymous public-form lookup picker, so lookup / master_detail / user fields are always stripped from the anonymous rendering and GET /forms/:slug/lookup/:field is no longer registered (#21180)", "ref": "claude/issue-21180-retire-public-picker" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "clause 2 and step 6 corrected (#23878 checklist-accuracy findings, re-measured on main c91af8ed6; comment 6225030762 withdrew only the heading pointer, which this item never carried). The anonymous submit's landed row has owner_id null and created_by null — there is no 'server-stamped owner' — while organization_id is the deployment's own organization; the forged values are stripped either way, which is what the verify already scores. Step 6 now says the public link /forms/contact-us redirects (302) to the public page /_console/f/contact-us on a console-serving boot, and that /_console/forms/:name is the authed internal route", + "ref": "#24036" } ] }, @@ -1632,7 +1675,7 @@ "title": "Share-link capability tokens: anon resolve renders the record minus redactFields, password/audience gates hold, revoke/expire refuse without leaking, the stored password hash never leaves the server, the password travels in the X-Share-Password header, and public answers are never cached", "since": "v16", "status": "active", - "revision": 6, + "revision": 7, "priority": "P2", "surface": "api", "personas": [ @@ -1657,7 +1700,7 @@ "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", + "mint audience:'signed_in'; anon resolve → 401 SIGN_IN_REQUIRED; mint audience:'email' (the mint needs emailAllowlist: [\"\"], or it answers 400 VALIDATION_FAILED) and resolve anonymously and as a signed-in member whose email is OFF the allowlist → 404 INVALID_OR_EXPIRED, byte-identical to an unknown-token resolve. Links that COMBINE an audience with a password are access-security.share-link-combined-gates' to score", "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 (un-passworded, link_only) token → 404 INVALID_OR_EXPIRED, byte-identical to the answer for an unknown token (fail-closed, #5190: \"that record is gone\" is itself information) — capture it beside an unknown-token resolve for the byte comparison", "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", @@ -1685,9 +1728,9 @@ "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)", + "clause": "audience gating holds: a signed_in-audience link answers 401 SIGN_IN_REQUIRED for an anonymous caller; an email-audience link answers a caller whose email is off the allowlist (anonymous or signed in) 404 INVALID_OR_EXPIRED, byte-identical to an unknown token — never the record, and no email-mismatch code that would turn the link into an allowlist oracle", "oracle": "api", - "verify": "signed_in resolve → 401 SIGN_IN_REQUIRED; email resolve with a non-allowlisted email → refused", + "verify": "signed_in resolve → 401 SIGN_IN_REQUIRED; email resolve with a non-allowlisted caller → 404 INVALID_OR_EXPIRED whose status and body equal an unknown-token resolve (resolveToken returns null)", "evidence": "the two traces" }, { @@ -1715,9 +1758,9 @@ "evidence": "the three traces and the preflight response headers" }, { - "clause": "public share-link answers are never cached: BOTH public routes (/:token/resolve and /:token/messages) answer Cache-Control: no-store and Vary: X-Share-Password, X-Share-Password-Encoding on EVERY outcome, success and refusal alike. The authenticated create, list and revoke routes do not carry these headers", + "clause": "public share-link answers are never cached: BOTH public routes (/:token/resolve and /:token/messages) answer Cache-Control: no-store and a Vary that INCLUDES X-Share-Password and X-Share-Password-Encoding on EVERY outcome, success and refusal alike (the CORS middleware appends Origin to the same header, so the live value is 'X-Share-Password, X-Share-Password-Encoding, Origin'). The authenticated create, list and revoke routes carry neither no-store nor the two share-password Vary members", "oracle": "api", - "verify": "each public response's headers carry exactly Cache-Control: no-store and Vary: X-Share-Password, X-Share-Password-Encoding (200, 401 x2, 404, 410, and the messages refusal); the authenticated list response carries neither", + "verify": "each public response's headers carry Cache-Control: no-store and a Vary whose members include X-Share-Password and X-Share-Password-Encoding (200, 401 NEEDS_PASSWORD, 401 WRONG_PASSWORD, 404, 410, and the /messages refusal); a public response missing either member, or missing no-store, is the fail. The authenticated create, list and revoke responses carry no Cache-Control: no-store and no share-password Vary member (measured: Vary: Origin only)", "evidence": "the header block of every public response and of the authenticated list response" } ], @@ -1795,6 +1838,12 @@ "date": "2026-10-07", "change": "the Vary clause and its verify re-pointed to the measured answer: both public routes now answer Vary: X-Share-Password, X-Share-Password-Encoding, because the answer also depends on the companion header that declares the password header's encoding (utf-8: percent-encoded UTF-8). Sources name the shared header reading and the companion header in the default CORS allow-list. No clause, step, negative or variant added or removed; a clause scoring the encoded form is left to a checklist sweep", "ref": "#22049" + }, + { + "revision": 7, + "date": "2026-10-16", + "change": "two clauses re-pointed to the measured answers on main c91af8ed6 (#23878 checklist-accuracy finding; #23879 out_of_scope_findings). (1) clause 9 demanded EXACTLY 'Vary: X-Share-Password, X-Share-Password-Encoding'; every public outcome (200, 401 x2, 404, 410, the /messages 400 UNSUPPORTED) answers 'X-Share-Password, X-Share-Password-Encoding, Origin' with no-store, because the CORS middleware appends Origin, and the authenticated create, list and revoke answer Vary: Origin only — the clause now asserts the two share-password members are INCLUDED and no-store present, which still fails if either is dropped. (2) the email-audience leg (step 5, clause 4) now states the v18 answer: a caller off the allowlist, anonymous or signed in, gets 404 INVALID_OR_EXPIRED byte-identical to an unknown token, and the mint needs emailAllowlist; combined audience + password links are pointed at access-security.share-link-combined-gates", + "ref": "#24036" } ] }, @@ -1921,7 +1970,7 @@ "title": "Authoring a sharing rule in Setup materializes matching grants for exactly the audience; deleting the rule retracts them", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": [ @@ -1932,12 +1981,12 @@ "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", + "the Setup 'Sharing Rules' surface (the nav_sharing_rules entry this repo's plugin-sharing contributes into the Setup app's group_access_control slot — packages/plugins/plugin-sharing/src/sharing-plugin.ts navigationContributions — opening the sys_sharing_rule object list and record form, requiredPermissions manage_platform_settings) OR the POST /api/v1/sharing/rules authoring endpoint", "a genuinely private-OWD object with authorable matching/non-matching rows: showcase_contact (sharingModel 'private', examples/app-showcase/src/data/objects/contact.object.ts) — its `stage` select (new | working | qualified | closed) is the criteria field. ⛔ NOT showcase_project: it declares sharingModel 'public_read_write', so its record baseline is already org-wide and a rule on it widens NOTHING observable (the pre-#7687 text called it private-OWD and was wrong). The other genuinely private-OWD showcase objects are showcase_inquiry and showcase_private_note", "an audience position holding object-level allowRead on showcase_contact at the DEFAULT (own) record scope — this needs a SCRATCH permission set bound to a scratch position, because stock's only showcase_contact grant is showcase_manager { allowRead: true, readScope: 'org' } (permission-sets.ts), whose org-depth read already sees every contact and would mask the widening; and no other stock set grants showcase_contact at all, so any other stock position is refused by the OBJECT gate before record scope is ever consulted" ], "knownGaps": [ - "authoring a rule in Setup issues a direct sys_sharing_rule insert (packages/spec/src/security/sharing.zod.ts). Targeting showcase_contact keeps this item disjoint from access-security.sharing-rules-widen structurally rather than by convention: the four seeded rules cover showcase_project (×2), showcase_inquiry and showcase_task (examples/app-showcase/src/security/sharing-rules.ts) and NONE covers showcase_contact, so no criteria-distinctness dance is needed — that item tests the SEEDED rules' enforcement, this one authors a rule on an object no seeded rule touches", + "authoring a rule in Setup issues a direct sys_sharing_rule insert (packages/spec/src/security/sharing.zod.ts). Stock seeds TWO rules (examples/app-showcase/src/security/sharing-rules.ts allSharingRules; the showcase_project and showcase_task rules were retired because a public_read_write OWD leaves nothing to widen): share_new_inquiries_with_field_ops on showcase_inquiry, and share_key_account_qualified_contacts_with_managers ON showcase_contact — condition record.stage == 'qualified' && record.company == 'Northwind', shared with the manager position. So a seeded rule DOES touch this item's object. It does not confound the item, for two reasons the run must keep true: neither scored persona holds the manager position (the audience holds the scratch position, the outsider holds neither; the seeded dev admin does hold manager and is never a scored reader here), and acceptance[2] attributes a share by source_id = the NEW rule's row id, so a seeded-rule share on a Northwind-qualified probe is a different row. That item tests the SEEDED rules' enforcement; this one authors and retracts a rule in-run", "the audience persona must be provisioned in-run (scratch permission set + scratch position, see fixtures.requires); a stock position cannot serve as the audience for a showcase_contact rule. Until that provisioning is scripted, treat a run that skips it as blocked(fixture) — never as a pass off the admin's own reads" ] }, @@ -1994,7 +2043,7 @@ ], "negative": [ "a rule that shares the NON-matching (stage='new') contact, 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 rule in-run on showcase_contact, which no seeded rule covers, and verifies authoring + retraction", + "do not overlap access-security.sharing-rules-widen: that item verifies the SEEDED rules' enforcement — this item authors a rule in-run on showcase_contact for a scratch position no seeded rule names (the seeded showcase_contact rule shares only with the manager position, see knownGaps), and verifies authoring + retraction", "⛔ a run that authors the rule on a public_read_write object (showcase_project, showcase_task, …) proves nothing: the baseline is already org-wide, so the audience persona would read the 'matching' row with or without the rule and the item passes vacuously — the object under test must be OWD private" ], "traps": [ @@ -2005,7 +2054,7 @@ "packages/rest/src/sharing-routes.ts#registerSharingRuleEndpoints (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 (nav_sharing_rules Setup nav → sys_sharing_rule)", + "packages/plugins/plugin-sharing/src/sharing-plugin.ts#nav_sharing_rules (this repo's plugin-sharing contributes the nav_sharing_rules entry into the Setup app's group_access_control slot → the sys_sharing_rule object; no objectui plugin-sharing package exists at objectui daeb77fc84dd or f8267df93aba)", "examples/app-showcase/src/data/objects/contact.object.ts#sharingModel (showcase_contact — sharingModel 'private', `stage` select) + src/security/permission-sets.ts (the showcase_manager readScope 'org' grant this item must avoid as its audience)", "cross-ref access-security.sharing-rules-widen (seeded-rule enforcement), ADR-0058 D3, ADR-0111 D6" ], @@ -2021,6 +2070,12 @@ "date": "2026-08-11", "change": "retargeted from showcase_project to showcase_contact (run #7637): showcase_project reports sharingModel 'public_read_write', so calling it 'a private-OWD object' was wrong and a rule on it widened nothing observable — the run substituted showcase_contact, which is genuinely OWD private and which no seeded rule covers, so the sharing-rules-widen non-overlap now holds structurally instead of by criteria convention. Criteria moves health=='green' → stage=='qualified'; the audience-provisioning requirement (scratch set + position, because stock's only showcase_contact grant reads org-wide) and a vacuous-pass negative are recorded. Same-fact fix as record-share-grant-revoke rev 2: source_id is the rule ROW ID, not the rule name", "ref": "#7687" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "knownGaps[0], negative[1], fixtures.requires[0] and source[3] corrected (#23878 checklist-accuracy findings, re-measured on main c91af8ed6). The stock boot seeds two sharing rules, not four, and one of them (share_key_account_qualified_contacts_with_managers) IS on showcase_contact, shared with the manager position; the text now says why that does not confound this item (no scored persona holds manager, and acceptance[2] keys on the new rule's source_id). The Setup nav citation named an objectui plugin-sharing file that does not exist at the objectui pin; the entry is contributed by this repo's packages/plugins/plugin-sharing/src/sharing-plugin.ts into the Setup app. No step, fixture request or oracle changed, so no re-run is owed", + "ref": "#24036" } ] }, @@ -2128,10 +2183,10 @@ }, { "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) on the PLATFORM action path; platform names cannot be shadowed", + "title": "A package-declared capability is served by the registry (no sys_capability row — the object is retired, ADR-0131 D3) and resolves across the grant/require three-way (ADR-0066) on the PLATFORM action path; platform names cannot be shadowed", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -2143,11 +2198,11 @@ "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", + "OpsPermissionSet granting it via systemPermissions (examples/app-showcase/src/security/permission-sets.ts) — the GRANT side. showcase_ops is a packaged set and SEALED: PUT /api/v1/meta/permission/showcase_ops answers 403 NOT_OVERRIDABLE (ADR-0126), so the REGRANT leg edits a CLONE of it — POST /api/v1/security/permission-sets/showcase_ops/clone {\"name\": \"\", \"label\": \"\"} (label is mandatory: a clone without one answers 400 VALIDATION_FAILED) carries showcase.export_data, no package, and is runtime-editable", "the shipped REQUIRE-side specimens examples/app-showcase/src/ui/actions/predicate-matrix.action.ts: showcase_zoo_perm_held (requires showcase.export_data — the GRANTED capability), showcase_zoo_perm_missing (requires showcase.restricted_ops — DEFINED but granted to nobody), showcase_zoo_perm_and (requires BOTH — the gate is an AND), showcase_zoo_perm_empty (requiredPermissions:[] — always passes). All four are type:'script' on the **showcase_field_zoo** object (predicate-matrix.action.ts binds objectName to a local const zoo = 'showcase_field_zoo' — `zoo` is the TS variable, never the object name), so the platform action path can invoke them" ], "knownGaps": [ - "the require side is exercised on the PLATFORM action path only — there is NO REST action-invoke route on this build and its absence is deliberate (#7680, ruling 2026-08-12): see the `data-actions` block comment in packages/rest/src/rest-route-ledger.ts. Which platform surface is reachable depends on the HTTP front the build under test boots (@objectstack/rest mounts neither /actions nor /mcp); the runner picks whichever of the dispatcher /actions domain, the MCP run_action bridge, or in-process ql.executeAction/invokeBusinessAction this build exposes, and RECORDS which — all three funnel through the single-sourced actionPermissionError" + "the require side is exercised on the PLATFORM action path. The surface to drive is the dispatcher /actions domain, mounted on the stock dev server: POST /api/v1/actions/showcase_field_zoo// (packages/runtime/src/domains/actions.ts → actionPermissionError) answers 200 {success, data} when the caller holds the capability and 403 PERMISSION_DENIED with the actionPermissionError sentence when not. @objectstack/rest itself still mounts no action-invoke route, by design (#7680, ruling 2026-08-12; the `data-actions` block comment in packages/rest/src/rest-route-ledger.ts): /api/v1/data/:object/:id/actions/... answers the routing 404 ENDPOINT_NOT_FOUND. The MCP run_action bridge and in-process ql.executeAction funnel through the same single-sourced gate, so a host that serves no dispatcher may drive one of those instead and RECORD which" ] }, "variants": [ @@ -2158,26 +2213,26 @@ "shadow-refusal — a declaration whose name collides with PLATFORM_CAPABILITY_NAMES, or with a name another package declares, is refused loudly by the registry's one-holder rule" ], "steps": [ - "boot showcase isolated; confirm the declared capability is served: GET /api/v1/meta/capability/showcase.export_data answers its declared name/label/description/scope, and no sys_capability row names it", + "boot showcase isolated; confirm the declared capability is served: GET /api/v1/meta/capability/showcase.export_data answers its declared name/label/description/scope, and GET /api/v1/data/sys_capability answers 404 OBJECT_NOT_FOUND (the object is retired, ADR-0131 D3 — there is no row table to find the capability in)", "read its fields — they are the declaration's own; nothing is transcribed into a row", - "identify which platform action-invoke surface this build exposes (dispatcher /actions domain · MCP run_action · in-process ql.executeAction/invokeBusinessAction) and record it; do NOT probe /api/v1/actions/... or /api/v1/data/:object/actions/... — @objectstack/rest mounts no action-invoke route by design (#7680), so a 404 there scores nothing", + "drive the dispatcher /actions domain (knownGaps): POST /api/v1/actions/showcase_field_zoo//, read the record id off GET /api/v1/data/showcase_field_zoo. Do NOT score /api/v1/data/:object/:id/actions/... — @objectstack/rest mounts no action-invoke route by design (#7680), so a 404 there scores nothing", "on that surface, INVOKE showcase_zoo_perm_held (requires showcase.export_data) as the GRANTED member holding OpsPermissionSet, then as a NON-granted member: granted executes, non-granted is refused with the actionPermissionError text naming the action and the missing capability", - "REGRANT leg: withdraw showcase.export_data from OpsPermissionSet's systemPermissions, re-invoke as the (formerly) granted member → now refused; restore the grant, re-invoke → executes again", + "REGRANT leg: showcase_ops is sealed (PUT /api/v1/meta/permission/showcase_ops → 403 NOT_OVERRIDABLE), so clone it (fixtures.requires[1]) and grant the CLONE to a fresh member through a sys_user_permission_set row; invoke showcase_zoo_perm_held as that member → executes; withdraw showcase.export_data from the CLONE's systemPermissions (PUT /api/v1/meta/permission/ with the read-back item minus that entry), re-invoke → refused; restore it, re-invoke → executes again", "invoke showcase_zoo_perm_missing (requires showcase.restricted_ops, granted to nobody) as EVERY persona incl. the OpsPermissionSet holder → refused for all; and showcase_zoo_perm_empty (requiredPermissions:[]) → executes for all, since 'declares nothing' is not 'denies everything'", - "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)" + "author a scratch package declaring a capability whose name is in PLATFORM_CAPABILITY_NAMES and BOOT it: os validate and os build both pass the declaration (the name is lexically valid), and the refusal is the registry's one-holder check at boot, which stops the process. The runtime door is closed to the same attempt: PUT /api/v1/meta/capability/ answers 403 NOT_OVERRIDABLE (capability is a code-only type)", + "the capability write doors validate before anything reaches the authorization namespace (the #5961 concern): author a scratch capability with a malformed shape (e.g. scope 'galaxy') in source and run os validate — refused at parse; the runtime metadata door refuses the type outright, PUT /api/v1/meta/capability/ → 403 NOT_CREATABLE (capability is allowRuntimeCreate:false and allowEnvironmentOverlay:false, packages/spec/src/kernel/metadata-plugin.zod.ts); and there is no sys_capability table to upsert into (step 1's 404)" ], "acceptance": [ { - "clause": "the package-declared capability is served by the registry with its authored identity, and no sys_capability row is written for it (ADR-0131 D3)", + "clause": "the package-declared capability is served by the registry with its authored identity, and no sys_capability row exists for it — the sys_capability object is retired (ADR-0131 D3)", "oracle": "api", - "verify": "GET /api/v1/meta/capability/showcase.export_data matches the declaration; the sys_capability read finds no row for it", + "verify": "GET /api/v1/meta/capability/showcase.export_data matches the declaration; GET /api/v1/data/sys_capability answers 404 OBJECT_NOT_FOUND ('Object 'sys_capability' is not registered')", "evidence": "the metadata read + the empty row read + the declaration source" }, { - "clause": "the three-way resolves by NAME on the surface that actually enforces it — the PLATFORM action path (ADR-0066 D4 actionPermissionError, reached via the dispatcher /actions domain, the MCP run_action bridge, or ql.executeAction): invoking an action that REQUIREs the capability is refused for a member whose granted sets do not carry it and executes for one that does, the refusal follows a grant WITHDRAWN from the permission set, and execution returns when it is re-granted — grant / deny / regrant, all three legs", + "clause": "the three-way resolves by NAME on the surface that actually enforces it — the PLATFORM action path (ADR-0066 D4 actionPermissionError, reached via the dispatcher /actions domain — POST /api/v1/actions/:object/:action/:recordId on the stock dev server — or, on a host without it, the MCP run_action bridge or ql.executeAction): invoking an action that REQUIREs the capability is refused for a member whose granted sets do not carry it and executes for one that does, the refusal follows a grant WITHDRAWN from the (cloned) permission set, and execution returns when it is re-granted — grant / deny / regrant, all three legs", "oracle": "api", - "verify": "on the platform surface this build exposes (recorded per steps): showcase_zoo_perm_held executes for the OpsPermissionSet holder and is refused for a non-granted member with the actionPermissionError string naming the action and the missing capability; after withdrawing showcase.export_data from OpsPermissionSet the SAME holder is refused, and after restoring it executes again. Cross-checks on the same surface: showcase_zoo_perm_missing (showcase.restricted_ops, granted to nobody) is refused for every persona, and showcase_zoo_perm_empty (requiredPermissions:[]) executes for every persona. This clause is NOT scored against @objectstack/rest — it mounts no action-invoke route by design (#7680)", + "verify": "on the dispatcher /actions domain (or the recorded alternative): showcase_zoo_perm_held answers 200 for the OpsPermissionSet holder and 403 PERMISSION_DENIED for a non-granted member, the message being the actionPermissionError sentence naming the action and the missing capability; a member holding the CLONE of showcase_ops (showcase_ops itself is sealed, 403 NOT_OVERRIDABLE) gets 200, then 403 after showcase.export_data is withdrawn from the clone, then 200 again after it is restored. Cross-checks on the same surface: showcase_zoo_perm_missing (showcase.restricted_ops, granted to nobody) is refused for every persona, and showcase_zoo_perm_empty (requiredPermissions:[]) executes for every persona. This clause is NOT scored against /api/v1/data/:object/:id/actions/... — @objectstack/rest mounts no action-invoke route by design (#7680)", "evidence": "the invocation results for all three legs (grant → deny → regrant) plus the two cross-checks, each with the refusal text or the executed result, and a note of which platform surface was driven" }, { @@ -2187,18 +2242,18 @@ "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)", + "clause": "no unvalidated capability can reach the authorization namespace (#5961): the only authoring door is source, which os validate / os build parse against the capability schema, the runtime metadata door refuses the type outright, and there is no sys_capability table to write into", "oracle": "build", - "verify": "a malformed capability shape is rejected at parse/validate, not silently upserted into sys_capability", - "evidence": "the rejection" + "verify": "a malformed capability shape in source is rejected at parse/validate; PUT /api/v1/meta/capability/ answers 403 NOT_CREATABLE whatever the body; GET or POST /api/v1/data/sys_capability answers 404 OBJECT_NOT_FOUND", + "evidence": "the validate rejection + the runtime-door refusal + the 404" } ], "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", + "a shadow declaration silently accepted (overwriting a platform capability) is a FAIL; an unvalidated capability reaching the authorization namespace (a runtime write the code-only type should refuse answering 2xx) is the #5961 authz-namespace-injection FAIL", "REQUIRE-side fail-open: a NON-granted member whose platform-path invocation of showcase_zoo_perm_held SUCCEEDS is the ADR-0066 D4 gate failing open — FAIL, and the server-side shape of the client-side fail-open #3923 reported. Likewise showcase_zoo_perm_missing executing for anyone (nobody is granted showcase.restricted_ops), or showcase_zoo_perm_and executing for a caller holding only showcase.export_data (the gate is an AND, not an OR)", - "stale-grant: after showcase.export_data is withdrawn from OpsPermissionSet, the former holder still executing showcase_zoo_perm_held is a FAIL — the gate is reading a cached grant, not the current permission set", + "stale-grant: after showcase.export_data is withdrawn from the cloned set, its holder still executing showcase_zoo_perm_held is a FAIL — the gate is reading a cached grant, not the current permission set", "over-block: the OpsPermissionSet holder REFUSED on showcase_zoo_perm_held, or showcase_zoo_perm_empty refused for anyone, is a FAIL in the other direction — 'declares nothing' is not 'denies everything'", - "a 404 from a REST action-invoke probe is NEITHER pass nor fail — that surface does not exist on this build by design (#7680). Scoring this clause off a REST 404, in either direction, is a mis-run: re-run it on the platform surface" + "a 404 from /api/v1/data/:object/:id/actions/... is NEITHER pass nor fail — @objectstack/rest mounts no action-invoke route by design (#7680). Scoring this clause off that 404, in either direction, is a mis-run: drive the dispatcher /actions domain" ], "traps": [ "wrong-persona", @@ -2228,7 +2283,8 @@ "change": "re-pointed the REQUIRE clause at the surface that enforces it (#7680, maintainer ruling 2026-08-12). The clause asked for a 403 from a REST resource carrying requiredPermissions; no such surface exists — @objectstack/rest mounts no action-invoke route, deliberately — so the clause was unrunnable as written and the run fell back to a platform capability. Now: grant/deny/regrant driven through the platform action path (actionPermissionError via the dispatcher /actions domain, MCP run_action, or ql.executeAction) against the four shipped predicate-matrix specimens, with the fail-open / stale-grant / over-block / AND-vs-OR failure modes spelled out in `negative`, and REST 404s explicitly scoring nothing. The knownGap moved from 'no resource REQUIREs it' (false — four actions do) to 'which platform surface the build exposes, recorded per run'", "ref": "claude/issue-7680-package-capability-doctrine" }, - { "revision": 3, "date": "2026-08-18", "change": "named the specimens' object correctly: showcase_field_zoo, not `zoo`. The fixture block quoted the local TS const from predicate-matrix.action.ts (const zoo = 'showcase_field_zoo'), so an invoke keyed on `zoo` answers 'Action ... not found on object zoo' — which reads like a missing specimen rather than a wrong object name (#9453 CF-6)", "ref": "#9386" } + { "revision": 3, "date": "2026-08-18", "change": "named the specimens' object correctly: showcase_field_zoo, not `zoo`. The fixture block quoted the local TS const from predicate-matrix.action.ts (const zoo = 'showcase_field_zoo'), so an invoke keyed on `zoo` answers 'Action ... not found on object zoo' — which reads like a missing specimen rather than a wrong object name (#9453 CF-6)", "ref": "#9386" }, + { "revision": 4, "date": "2026-10-16", "change": "re-pointed against main c91af8ed6 (#23878 checklist-accuracy findings, #23879 out_of_scope_findings). (1) sys_capability is retired (GET /api/v1/data/sys_capability → 404 OBJECT_NOT_FOUND), so the title, step 1, clause 1 and the old steps 7-8 no longer describe a sys_capability row or upsert; clause 4 now states the doors that exist: source parsed by os validate, the runtime metadata door refusing the code-only type (403 NOT_CREATABLE), and no table. (2) The dispatcher /actions domain IS mounted on the stock dev server and enforces actionPermissionError (measured: held 200 for the ops holder, 403 PERMISSION_DENIED naming the missing capability for a plain member; missing and and refused, empty executes), so step 3 and the knownGap name it as the surface; /api/v1/data/:object/:id/actions/... stays the routing 404. (3) showcase_ops is sealed (PUT → 403 NOT_OVERRIDABLE, ADR-0126), so the REGRANT leg withdraws and restores the capability on a clone made through POST /api/v1/security/permission-sets/showcase_ops/clone (label mandatory); measured 200 → 403 → 200. The clause still fails if a non-holder executes, a withdrawn grant keeps executing or a holder is refused. The build-oracle clauses re-run on a minimal scratch stack: a capability with scope 'galaxy' → os validate exit 1 \"capabilities.0.scope: Invalid value 'galaxy'. Expected one of: platform, org.\"; a capability named manage_platform_settings → os validate and os build exit 0, and os dev refused to boot with '✗ Security catalog name conflict: package \"com.qa.caps\" cannot register a name another holder already holds — capability \"manage_platform_settings\" is already held by the platform …' (step 6 now says the refusal is at boot); the runtime door answered both a platform name and the malformed body 403 (sealed / code-only type)", "ref": "#24036" } ] }, { @@ -2351,7 +2407,7 @@ "title": "Activation-ledger write authority (ADR-0126 §5): the manage_metadata tier refuses first on every posture, and the shared operator gate — INERT on stock `single` — demands the platform_admin POSITION in walled postures, at both the flow and action doors", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -2363,7 +2419,7 @@ "fixtures": { "app": "showcase", "requires": [ - "stock showcase boots the `single` tenancy posture (no tenancy service / ADR-0093 D4-D5 fallback) — CONFIRM the posture before scoring, because it decides which gate can answer at all", + "stock showcase boots the `single` tenancy posture (no tenancy service / ADR-0093 D4-D5 fallback) — CONFIRM the posture before scoring, because it decides which gate can answer at all. No API exposes the posture; the proof is the boot banner line `Tenancy: single` (printed at every log level), corroborated at OS_LOG_LEVEL=info by the log line \"[security] tenancy posture 'single' — Layer 0 is inert …\"", "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts) for POST /api/v1/automation/showcase_urgent_task_alert/toggle", "a packaged action for POST /api/v1/actions/_activation/showcase_task/showcase_mark_done (examples/app-showcase/src/ui/actions/index.ts)", "a runtime-authored scratch permission set with system_permissions ['manage_metadata'] granted to a fresh member via a sys_user_permission_set row (same runtime-authoring pattern access-security.fls-mask-and-strip's read half uses) — no stock showcase set grants manage_metadata" @@ -2374,7 +2430,7 @@ ] }, "steps": [ - "boot showcase isolated; admin session; PROVE the posture is `single` (no tenancy service resolves / the deployment declares none) and record the evidence — the posture selects which gate can answer", + "boot showcase isolated; admin session; PROVE the posture is `single` and record the evidence — the boot banner's `Tenancy: single` line (no API reports the posture; at OS_LOG_LEVEL=info the \"[security] tenancy posture 'single'\" line corroborates it) — the posture selects which gate can answer", "as the plain member: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false} — capture status, code and the EXACT message string", "as the plain member: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} — capture status, code and the exact message", "as the plain member again: repeat the flow toggle with a deliberately INVALID body (e.g. {\"enabled\": \"false\"}) — the refusal must still be the 403, not a 400 body-validation answer (the gate runs before body validation, automation.ts; activation-gate.ts)", @@ -2462,6 +2518,12 @@ "date": "2026-09-04", "change": "read-back detail only: #15024 dropped sys_metadata_activation's reserved organization_id column, so the two ledger read-backs (step 7 and the inert-gate clause) asked the runner to see a column that is gone. They now read the row's KEY SET instead — a value check of the shape `row.organization_id ?? null` is `null` for a column that does not exist, so carrying the old spelling forward would have left a leg that passes while measuring nothing. ⚠️ The gate itself is UNCHANGED: ADR-0126 §5's operator-gate half stands and its unit pins pass unmodified; only §5's separate 'the org column is reserved' bullet was withdrawn (ADR-0131 D7). The source citation now says so, so the next reader does not infer the gate moved too", "ref": "#15154" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "step 1 and the fixture line name the posture oracle (#23878 checklist-accuracy finding): no API exposes the tenancy posture, and the proof the run used is the boot banner line 'Tenancy: single', which re-measured on main c91af8ed6 prints at any log level, with the info-level \"[security] tenancy posture 'single' — Layer 0 is inert\" line as corroboration. Re-run on that boot: the flow door and the action door both answer the plain member 403 PERMISSION_DENIED with the manage_metadata tier sentences, and the invalid body is still 403", + "ref": "#24036" } ] }, @@ -2662,7 +2724,7 @@ "title": "OS_PLATFORM_OWNER_EMAIL platform-admin anchor: only a VERIFIED stored-email match confers PLATFORM_ADMIN — unset, unverified match, non-match and malformed lists each confer nothing, fail closed", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -2676,15 +2738,15 @@ "a manage_platform_settings-floor probe route: GET /api/v1/datasources answers 401/403 BEFORE any service resolution (packages/services/service-datasource/src/admin-routes.ts; DATASOURCE_ADMIN_CAPABILITY), and admin_full_access carries that capability (packages/spec/src/identity/eval-user.zod.ts) — so the probe discriminates platform-admin standing cleanly" ], "knownGaps": [ - "the ENTITLED live leg needs a VERIFIED second account, and no stock HTTP path verifies one: email_verified is statically readonly at the user-context API (stripped — the write-path-guards class), a fresh sign-up reads unverified, and the seeded dev admin — whose address IS stamped verified at seed time (#11343, auth-plugin.ts) — also holds the unscoped admin_full_access grant under single (the posture this item boots), so its 200s can never attribute to the config anchor. Verify M's address via a verification-enabled boot with mail capture (identity-auth.email-verification-loop's fixture, itself a knownGap there) or a system-context stamp through the same isSystem doorway the seed itself uses; without either, clause 5's live leg scores blocked(fixture) and the entitled side rests on the unit pin" + "the ENTITLED live leg needs a VERIFIED second account, and no stock HTTP path verifies one: email_verified is statically readonly at the user-context API (stripped — the write-path-guards class), a fresh sign-up reads unverified, and the seeded dev admin — whose address IS stamped verified at seed time (#11343, auth-plugin.ts) — also holds the unscoped admin_full_access grant under single (the posture this item boots), so its 200s can never attribute to the config anchor. Verify M's address via a verification-enabled boot with mail capture (identity-auth.email-verification-loop's fixture, itself a knownGap there) or a system-context stamp through the same isSystem doorway the seed itself uses; without either, clause 5's live leg scores blocked(fixture) and the entitled side rests on the unit pin. Measured on main c91af8ed6: POST /api/v1/auth/admin/update-user {userId, data: {emailVerified: true}} answers the seeded dev admin 403 YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS, so the HTTP lane has no verifying door; the run stamped M's email_verified directly on its own isolated scratch database with the server stopped (a system-level write, never on a shared database) and records that it did" ] }, "steps": [ "boot showcase single (OS_TENANCY_POSTURE=single — also what a deployment with no tenancy configuration resolves to) with the variable UNSET — a walled posture (group / isolated) refuses to boot with it unset (plugin-auth init; out of scope here, see negative) — and keep that posture for every restart below; admin session; sign up fresh member M and record M's email; as M: GET /api/v1/datasources and GET /api/v1/auth/me/permissions (read systemPermissions) — the baseline refusals", "restart with OS_PLATFORM_OWNER_EMAIL= (M's row is unverified); as M repeat both probes", "restart with OS_PLATFORM_OWNER_EMAIL=; repeat M's probes", - "restart with a malformed list, e.g. OS_PLATFORM_OWNER_EMAIL=', not an address'; capture the [authz] refusal from the process log (it prints once, on the first derivation after the value is seen); as M repeat the probes — the WHOLE variable is refused, so even the well-formed entry confers nothing", - "entitled side (see knownGaps for the verified-member fixture): with M's row verified and the variable naming M, as M repeat the probes and read /api/v1/auth/me/permissions — expect the declared admin envelope; where the fixture is unavailable, run the unit pin and cite its output instead", + "entitled side (see knownGaps for the verified-member fixture): with M's row verified and the variable naming M, as M repeat the probes and read /api/v1/auth/me/permissions — expect the declared admin envelope; where the fixture is unavailable, run the unit pin and cite its output instead. This step runs BEFORE the malformed variant on purpose: the malformed list's 'the well-formed sibling confers nothing' check only discriminates once M is verified", + "with M's row STILL VERIFIED from the previous step, restart with a malformed list, e.g. OS_PLATFORM_OWNER_EMAIL=', not an address'; capture the [authz] refusal from the process log (it prints once, on the first derivation after the value is seen); as M repeat the probes — the WHOLE variable is refused, so even the well-formed entry confers nothing. Run unverified, M's refusal here could not be told apart from clause 2's unverified arm; verified, a well-formed variable naming M would confer standing (the previous step shows it does), so the refusal is attributable to the malformed entry alone", "the walled half of the legacy grant anchor: run the #11663 L5 posture-split unit pins named in clause 6 and cite their output. There is no live log line to grep — the once-per-process legacy-grant re-anchor pointer was retired with the walled anchor (#19136), and nothing replaces it on the request path; the only operator-facing line left is plugin-security's boot-time fail-closed error, which fires when a walled rig has no usable configured administrator" ], "acceptance": [ @@ -2709,7 +2771,7 @@ { "clause": "any malformed entry fails the WHOLE variable closed, loudly once: the process log carries the [authz] refusal naming the offending entry verbatim and stating the deployment now has ZERO config-derived platform administrators (parsePlatformAdminEmails, platform-admin.ts; sink) — and the well-formed sibling entry on the same list confers nothing (never skip-and-continue)", "oracle": "log", - "verify": "log line matches the refusal text and names the entry; M (the well-formed entry) still refused on the datasource probe", + "verify": "log line matches the refusal text and names the entry; M (the well-formed entry) still refused on the datasource probe WHILE M's row is verified — run after the entitled step, so the same well-formed address is known to confer standing when the variable is accepted", "evidence": "the log excerpt + M's trace" }, { @@ -2771,6 +2833,12 @@ "date": "2026-09-23", "change": "re-anchored on the walled retirement of the legacy grant anchor (#19136, issue #19145): step 6 and clause 6 told the runner to grep the process log for the once-per-process re-anchor pointer and cited reportLegacyPlatformAdminGrant, both removed — clause 6 now states the posture split (an anchor under single only, silent everywhere) with the #11663 L5 unit pins as its oracle, and the source parenthetical names live symbols. The item's live legs move from isolated to single: an isolated boot with the variable unset refuses to start (plugin-auth init), and under a walled posture the seeded admin holds no grant-anchored standing, so clauses 1 and 3 and the seeded-admin both-sides control only hold under single. Persona and knownGap state the posture. The single half is described as it ships; its fate is #11979's", "ref": "#19145" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps reordered (#23878 checklist-accuracy finding): the malformed-list variant ran while M was still unverified, so its refusal could not be told apart from the unverified arm, and the 'well-formed sibling confers nothing' check only bites once M is verified. The entitled step now precedes the malformed one, which keeps M verified, and clause 4's verify says so; knownGaps[0] records how the verified fixture was made (the admin update-user door refuses; a direct email_verified stamp on the isolated scratch DB). Re-run on main c91af8ed6, single posture, same DB across restarts: var unset → M 403, systemPermissions []; var = M unverified → 403, []; var = a non-matching address → 403, []; M verified and var = M in another case → datasources 200, 9 system permissions incl. manage_platform_settings; var = 'M, not an address' with M verified → 403, [] and the [authz] whole-variable refusal naming the entry; the admin answered 200 on every boot", + "ref": "#24036" } ] }, @@ -2904,7 +2972,7 @@ "title": "Opt-in record-view auditing writes `read` rows for exactly the opted-in objects' detail reads, batched off the request path — stock boot audits nothing, excluded names refuse loudly, values never copied", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -2914,26 +2982,26 @@ "fixtures": { "app": "showcase", "requires": [ - "stock showcase boot for the honest-default half (grepped: nothing under examples/app-showcase constructs an AuditPlugin or names readAudit — the CLI's auto-registered instance passes NO options and audits no views)", + "stock showcase boot for the honest-default half (grepped: nothing under examples/app-showcase constructs an AuditPlugin or names readAudit — the CLI's auto-registered instance passes NO options and audits no views); boot it at OS_LOG_LEVEL=info so the install-line grep can discriminate", "read the rows through GET /api/v1/data/sys_audit_log — NEVER by opening a SQLite file directly: sys_audit_log's ADR-0057 lifecycle class routes it to the dedicated telemetry datasource whenever one is registered (os dev provisions one by default as a sibling file), and the data API resolves the right one; the write-failure text at read-audit.ts documents exactly this trap" ], "knownGaps": [ - "the CONFIGURED half has no stock fixture and the provisioning is a BOOT-CONFIG change, not an API sequence — so no area recipe fits. Compose the docs' own snippet (content/docs/permissions/record-view-auditing.mdx, 'Under os serve'): a scratch app whose objectstack.config.ts plugins array carries new AuditPlugin({ readAudit: { objects: ['', 'sys_job'] } }) — sys_job included deliberately as the excluded-name probe. The 'Plugin superseded: com.objectstack.audit' boot line is the opt-in WORKING (last-one-wins over the CLI's option-less instance), not a misconfiguration. Without such a boot, clauses 2-5 score blocked(fixture); clause 1 (the stock negative) runs regardless", + "the CONFIGURED half has no stock fixture and the provisioning is a BOOT-CONFIG change, not an API sequence — so no area recipe fits. Compose the docs' own snippet (content/docs/permissions/record-view-auditing.mdx, 'Under os serve'): a scratch app whose objectstack.config.ts plugins array carries new AuditPlugin({ readAudit: { objects: ['', 'sys_job'] } }) — sys_job included deliberately as the excluded-name probe. The 'Plugin superseded: com.objectstack.audit' boot line is the opt-in WORKING (last-one-wins over the CLI's option-less instance), not a misconfiguration. Scripted scratch-copy recipe (measured on main c91af8ed6, #24036): copy examples/app-showcase without node_modules, dist, e2e and test into a scratch directory; give the copy a REAL node_modules directory holding one symlink per entry of examples/app-showcase/node_modules (each @objectstack/* entry pointed at its workspace package), plus @objectstack/plugin-audit → packages/plugins/plugin-audit and @objectstack/platform-objects → packages/platform-objects, which the showcase does not depend on; add `import { AuditPlugin } from '@objectstack/plugin-audit'` and put `new AuditPlugin({ readAudit: { objects: ['showcase_account', 'sys_job'] } })` first in the plugins array; boot with the copy's node_modules/.bin/objectstack dev --seed-admin -p PORT -d file:SCRATCH_DB, OS_PORT exported and OS_LOG_LEVEL=info. It is a boot-config copy, not an API sequence, so it lives here and not as an area recipe. Without such a boot, clauses 2-5 score blocked(fixture); clause 1 (the stock negative) runs regardless", "there is deliberately NO enable.auditReads object-metadata key, no stack-config key and no env flag (the docs state why: a declaration that can outlive the plugin reads as coverage while recording nothing) — a runner hunting for a config knob on the object will conclude the capability is missing (absence-inference); the opt-in lives ONLY where the plugin is constructed" ] }, "steps": [ - "stock leg: boot showcase stock; as M open a record detail (GET /api/v1/data/showcase_contact/); wait past the 2s default flush window; as admin GET /api/v1/data/sys_audit_log with a $filter on action 'read' — expect zero rows; the boot log must carry NO 'record-view auditing installed' line", - "configured leg (see knownGaps): boot the scratch app with readAudit.objects naming object X plus the excluded sys_job; read the boot log for the install line and the exclusion warning", + "stock leg: boot showcase stock with OS_LOG_LEVEL=info (or --log-level info) — os dev defaults to warn, and the install line logs at INFO, so on a default boot its absence proves nothing; confirm INFO lines are present in the boot log as the positive control. As M open a record detail M can READ — GET /api/v1/data/showcase_account/ answering 200 (a baseline member is refused showcase_contact at the object gate with 403, and a refused read audits nothing under any configuration, so a contact specimen makes the zero vacuous); wait past the 2s default flush window; as admin GET /api/v1/data/sys_audit_log with a $filter on action 'read' — expect zero rows; the boot log must carry NO 'record-view auditing installed' line", + "configured leg (the scratch-copy recipe in knownGaps[0]): boot the scratch copy with readAudit.objects naming X = showcase_account plus the excluded sys_job, at OS_LOG_LEVEL=info; read the boot log for the install line and the exclusion warning", "as M (user context): GET /api/v1/data/X/; wait past the flush window (or drive maxBatchSize reads); as admin read the ledger rows for action 'read'", - "as M: LIST /api/v1/data/X (a collection read) and GET a detail of an object NOT in the opt-in — then re-read the ledger", + "as M: LIST /api/v1/data/X (a collection read) and GET a detail of an object NOT in the opt-in that M can read (showcase_project) — then re-read the ledger", "inspect the landed row's fields: action, user_id, object_name, record_id, old_value, new_value, created_at (compare created_at against the wall clock of the read, not of the flush)" ], "acceptance": [ { "clause": "stock boot audits nothing — the honest default: os serve/dev auto-registers AuditPlugin with NO options, so a stock boot writes zero action:'read' rows however many records are opened, and logs no install line (a deployment that opts nothing in pays nothing)", "oracle": "api", - "verify": "post-read ledger query for action 'read' returns 0 rows on the stock boot; boot log lacks 'record-view auditing installed'", + "verify": "post-read ledger query for action 'read' returns 0 rows on the stock boot after a detail read that answered 200; boot log, at info level with INFO lines present, lacks 'record-view auditing installed'", "evidence": "the filtered query + the log grep" }, { @@ -3001,6 +3069,12 @@ "date": "2026-08-30", "change": "new — the #8992 record-view audit writer had no checklist coverage: the honest default (stock boot audits nothing), the closed per-object opt-in, the detail-read discriminator, the no-values row shape and the loud exclusion refusal were all unasserted. The configured half is fixture-gapped (a boot-config change per the docs' own snippet — recorded as knownGaps, not an area recipe, since it is not an API sequence); the stock negative runs on stock", "ref": "#8992" + }, + { + "revision": 2, + "date": "2026-10-17", + "change": "re-measured on main c91af8ed6 (checklist-accuracy finding from #23878): the stock leg's install-line grep was vacuous on a default boot, because the line logs at INFO and os dev defaults to warn, so steps[0], the fixture and clause 0's verify now boot at OS_LOG_LEVEL=info with an INFO-line positive control. The stock specimen GET /data/showcase_contact/ID is refused to a baseline member at the object gate (403), so the zero proved nothing either; the step now reads a showcase_account detail (200). knownGaps[0] gains the scripted scratch-copy recipe for the configured half, and steps 1 and 3 name X = showcase_account and the un-opted showcase_project. Re-run: stock boot at info (649 INFO lines), plain member detail reads of showcase_project, showcase_task and showcase_account all 200, action 'read' rows 0, no install line. Configured scratch copy: 'Plugin superseded: com.objectstack.audit' WARN, the sys_job exclusion WARN and 'AuditPlugin: record-view auditing installed on 1 object(s) — showcase_account'; member M's account detail read → 1 row (action read, user_id M, object_name showcase_account, record_id correct, old_value and new_value null, created_at 00:15:23.489Z inside the 23.385–23.497 read window, about 4 s before the flush read); the list read and the showcase_project detail read left the count at 1; the admin's detail read made it 2", + "ref": "#24036" } ] }, @@ -3009,7 +3083,7 @@ "title": "The /s/:token share-link landing page renders the resolved record minus redacted fields, gates on password, and refuses dead links with designed states — never a blank page or a raw error", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "browser", "personas": [ @@ -3026,7 +3100,7 @@ "knownGaps": [ "automated.ref lives ENTIRELY in the objectui repo — from this checkout it is neither runnable nor pin-evidenced (the RUNNER standing fact): drive the item by hand in the browser, or run the pin in an objectui checkout and name the revision", "the ai_conversations transcript branch (SharedRecordPage.tsx,190-234 — messages fetch + ChatbotEnhanced readOnly) is ai_conversations-only (Cloud/EE service-ai) — the same knownGap split the API item records for /:token/messages; not runnable on open-framework showcase", - "FIXED, regression row, expected PASS (objectui#11649): the page no longer folds every 401 into the password prompt. At objectui 1fb1ced36243, still this repo's .objectui-sha, SharedRecordPage.tsx reads a 401 by the body's error.code through shared-record-shape.ts resolveGateOf — NEEDS_PASSWORD and WRONG_PASSWORD open the password prompt, SIGN_IN_REQUIRED opens the sign-in gate, and any other code shows the server's message, not a prompt — so a signed_in-audience link opened anonymously renders the dedicated 'Sign in required' state, never 'Password required'. acceptance[3] now asserts that state (QA run #23163 saw it live at objectstack 36b2889c). The objectui pins are apps/console/src/pages/SharedRecordPage-11649.test.tsx ('shows the sign-in path back to this link, and no password prompt') and shared-record-shape.test.ts ('reads each 401 code as its gate'), objectui-repo-only like knownGaps[0]" + "FIXED, regression row, expected PASS (objectui#11649): the page no longer folds every 401 into the password prompt. Measured at objectui 1fb1ced36243 (the pin this row was written at), again at daeb77fc84dd (#23878) and at f8267df93aba (this repo's .objectui-sha when revision 3 was written; re-read it at whatever pin the run boots): SharedRecordPage.tsx reads a 401 by the body's error.code through shared-record-shape.ts resolveGateOf — NEEDS_PASSWORD and WRONG_PASSWORD open the password prompt, SIGN_IN_REQUIRED opens the sign-in gate, and any other code shows the server's message, not a prompt — so a signed_in-audience link opened anonymously renders the dedicated 'Sign in required' state, never 'Password required'. acceptance[3] now asserts that state (QA run #23163 saw it live at objectstack 36b2889c). The objectui pins are apps/console/src/pages/SharedRecordPage-11649.test.tsx ('shows the sign-in path back to this link, and no password prompt') and shared-record-shape.test.ts ('reads each 401 code as its gate'), objectui-repo-only like knownGaps[0]" ] }, "steps": [ @@ -3112,6 +3186,12 @@ "date": "2026-10-13", "change": "knownGaps[2], acceptance[3].clause, acceptance[3].verify and variants[5] (knownGap stale, #23163 C3; parked on #23121, carried by #23169). The page no longer folds every 401 into the password prompt: at objectui 1fb1ced36243, still this repo's .objectui-sha, SharedRecordPage.tsx reads a 401 by its error.code (shared-record-shape.ts resolveGateOf, objectui#11649) and renders a dedicated 'Sign in required' state, with a 'Sign in' link back to this link, for SIGN_IN_REQUIRED. The server half is unchanged at 87e902dcbb: both resolve mounts, packages/plugins/plugin-sharing/src/share-link-routes.ts and its dispatcher twin packages/runtime/src/domains/share-links.ts, answer an anonymous open of a live signed_in link with no password with 401 SIGN_IN_REQUIRED. As the knownGap itself asked, it becomes a regression row, and acceptance[3] (the item's clause 4) asserts the sign-in state instead of recording whichever state renders. Read from source at both pins; no browser was driven and no app was booted. QA run #23163 saw the state live", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "knownGaps[2] stopped naming objectui 1fb1ced36243 as 'still this repo's .objectui-sha' (#23878 checklist-accuracy finding). The pin moved to daeb77fc84dd and then to f8267df93aba; at f8267df93aba SharedRecordPage.tsx still reads a 401 by its code through shared-record-shape.ts resolveGateOf, renders the 'Sign in required' state for SIGN_IN_REQUIRED, and both objectui pins (SharedRecordPage-11649.test.tsx, shared-record-shape.test.ts) are present. The revision 2 history row stays as written: it was true at its pin. Wording only, no re-run owed", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/ai.json b/docs/qa/platform-checklist/areas/ai.json index 597ffaf4b7d..cbb34c2df33 100644 --- a/docs/qa/platform-checklist/areas/ai.json +++ b/docs/qa/platform-checklist/areas/ai.json @@ -9,7 +9,7 @@ "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": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -68,7 +68,7 @@ { "clause": "the runtime write door matches the declared registry posture — ALL sides: PUT ?mode=draft succeeds for a RUNTIME-CREATED skill (and tool) name — one no package ships (allowRuntimeCreate:true); the same PUT against a PACKAGE-SHIPPED skill/tool is refused 403 NOT_OVERRIDABLE (allowEnvironmentOverlay:false, ee58392e1 — ADR-0005 ai row); and 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": "the runtime-created skill draft PUT (qa_runtime_skill) returns 200 and the draft is readable via GET /api/v1/meta/skill/qa_runtime_skill?state=draft — ⚠️ the READ parameter is ?state=draft, not ?mode=draft: rest-server.ts keys the draft projection on req.query.state, while ?mode=draft is the WRITE-side parameter, so re-reading with ?mode=draft serves the active item (or 404s when none is active) and reads like the draft never landed. The draft PUT against the package-shipped qa_probe_skill answers 403 code NOT_OVERRIDABLE (sys-metadata-repository.ts assertAllowed, override-artifact intent) — CORRECT, not a bug: a 200 there would be a per-org overlay the registry row forbids. The agent runtime save is refused/unsupported (capture the actual status), and that refusal is recorded as CORRECT, not filed as a bug", + "verify": "the runtime-created skill draft PUT (qa_runtime_skill) returns 200 and the draft is readable via GET /api/v1/meta/skill/qa_runtime_skill?state=draft — ⚠️ the READ parameter is ?state=draft, not ?mode=draft: rest-server.ts keys the draft projection on req.query.state, while ?mode=draft is the WRITE-side parameter, and a GET carrying ?mode=draft answers 400 VALIDATION_ERROR ('The \"mode\" query parameter is not supported by this endpoint') — the read door refuses a parameter it does not understand instead of serving the active item. The draft PUT against the package-shipped qa_probe_skill answers 403 code NOT_OVERRIDABLE (sys-metadata-repository.ts assertAllowed, override-artifact intent) — CORRECT, not a bug: a 200 there would be a per-org overlay the registry row forbids. 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 three PUT responses (runtime-created skill 200 + its ?state=draft read, package-shipped skill 403, agent refusal)" }, { @@ -107,6 +107,12 @@ "date": "2026-10-04", "change": "A4 / step 7 re-pointed: the draft PUT targets a RUNTIME-CREATED skill/tool name (200, readable via ?state=draft); the same PUT against the package-shipped probe is 403 NOT_OVERRIDABLE, recorded as correct — tool/skill allowOrgOverride was rolled back to false (metadata-plugin.zod.ts tool/skill registry rows, sys-metadata-repository.ts assertAllowed). Assertion defect: the clause expected an overlay of a package-shipped skill to succeed. Source ee58392e1 (#6608)", "ref": "#21735" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "clause 4's verify said a GET with ?mode=draft serves the active item or 404s (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: PUT /meta/skill/qa_runtime_skill_24036?mode=draft → 200 state draft; GET ?state=draft → 200 with the draft; GET ?mode=draft → 400 VALIDATION_ERROR naming the unsupported mode parameter; the bare GET → 404 (no active item). The agent runtime save answers 403 NOT_CREATABLE (code-only type). The read-parameter warning keeps its point with the current answer", + "ref": "#24036" } ] }, @@ -115,7 +121,7 @@ "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, + "revision": 3, "priority": "P1", "surface": "api", "fixtures": { @@ -165,9 +171,9 @@ "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", + "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, resume_run, 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)", + "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, and resume_run only when the host cannot resume runs — both graceful degradation; record which)", "evidence": "the session trace + tool-name list" }, { @@ -194,7 +200,8 @@ ], "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" } + { "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-10-16", "change": "clause 5's tool list omitted resume_run (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 with an admin osk_ key: tools/list returns twelve tools, resume_run among them; mcp-http-tools.ts registers it only when the bridge exposes resumeRun, the same optional shape as aggregate_records, so the verify now names that degradation too", "ref": "#24036" } ] }, { @@ -202,7 +209,7 @@ "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": 5, + "revision": 6, "priority": "P0", "surface": "cli", "personas": ["restricted member API key (for the RLS half — on showcase, a contributor-bound member)", "a second restricted member API key for the FLS half (on showcase, a client_liaison-bound member — see fixtures.requires)", "admin API key (for the full-row comparison)"], @@ -210,7 +217,7 @@ "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)", + "two osk_ API keys: one bound to a restricted member, one to an admin (Setup → Connect an Agent, or POST /api/v1/keys). Minting them on the os start boot: an auth POST that carries a Cookie header and no Origin (a curl cookie jar, for one) is refused there with 403 MISSING_OR_NULL_ORIGIN, while os dev accepts it, so send an Origin header naming the boot (Origin: http://localhost:PORT) or sign in without a cookie jar, then POST /api/v1/keys with the bearer token", "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", "⚠️ on showcase NO single persona carries both RLS-hidden rows and an FLS-masked field on one object, so the RLS and FLS halves take two personas. RLS: a contributor-bound member (the search:qa-contributor-bound-member recipe) reads only its own showcase_invoice rows. FLS: the only `readable: false` grants are showcase_client_liaison's on showcase_project.budget / spent / budget_remaining (permission-sets.ts ClientLiaisonPermissionSet), and showcase_project is public_read_write, so that persona has no RLS-hidden rows there. Mint a key for each; score the RLS legs of acceptance[3] and acceptance[4] on the first, the FLS legs of acceptance[3] and acceptance[5] on the second" ], @@ -315,6 +322,12 @@ "date": "2026-10-16", "change": "stale clause re-pointed (#23950, from QA run #23915): acceptance[6] pinned the generic text 'MCP stdio identity is no longer valid (key revoked or expired)', but since #23367 (API-key lifecycle) resolvePrincipal raises the key's branded ADR-0112 refusal, so a revoked key's next stdio call is an isError tool result carrying code API_KEY_REVOKED, status 403 and the key's own lifecycle message. The clause now scores that code (and pins none of the message's wording, which the lifecycle owner rewords under #23944), no row served after the revoke (the fail-closed property it exists for, unchanged) and a pre-revoke control read on the same session; its oracle is the tool result (api), not a log line. Same ruling, same item: acceptance[1] attributed one message to unknown, revoked and expired keys alike, while the up-front admission now refuses a lifecycle-refused key with '[MCP] OS_MCP_STDIO_API_KEY is refused (API_KEY_REVOKED): …', so it names both messages and gains a leg (a new step starts stdio with the just-revoked key). A negative pins both failure directions", "ref": "#23950" + }, + { + "revision": 6, + "date": "2026-10-17", + "change": "runner note on minting the fixture keys on the os start boot (residual from the #23878 close-out, after PR #24000): measured on main c91af8ed6, os start answers a sign-in that carries a Cookie header and no Origin with 403 MISSING_OR_NULL_ORIGIN while os dev answers it 200; a cookie-less sign-in without Origin, and POST /api/v1/keys by session cookie or bearer without Origin, answer 200/201 on both. The fixture line now says so. No step, request or oracle changed", + "ref": "#24036" } ] }, @@ -323,7 +336,7 @@ "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, + "revision": 3, "priority": "P1", "surface": "api", "fixtures": { @@ -335,7 +348,7 @@ ] }, "steps": [ - "over MCP, call list_actions (no objectName, then objectName: 'showcase_task'); record which actions appear", + "over MCP, call list_actions with NO arguments (its input schema is strict and empty: an objectName argument is refused — the tool result is isError with 'MCP error -32602: Input validation error … Unrecognized key(s) on this list_actions call: `objectName`'); 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 })", @@ -388,7 +401,8 @@ ], "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" } + { "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-10-16", "change": "step 1 called list_actions with objectName: 'showcase_task' as a second form (#23878 checklist-accuracy finding). list_actions takes no arguments: re-measured on main c91af8ed6 the objectName call answers isError with MCP error -32602 'Unrecognized key(s) … objectName', while the no-argument call returns [showcase_portfolio_snapshot]. Re-run of the item's clauses on that boot: showcase_mark_done refused with the actionAiExposureError prescription and the task unchanged; showcase_portfolio_snapshot returns accounts 14 / projects 5 / invoices 12, equal to the REST totals; the [action-audit] MCP run_action line names the caller; a sys_* object is refused", "ref": "#24036" } ] }, { @@ -396,14 +410,14 @@ "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, + "revision": 3, "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)" + "a seeded object with number + text + date fields to probe against (showcase_task: estimate_hours/title/created_at qualify)" ] }, "variants": [ @@ -413,10 +427,10 @@ "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", + "over MCP, call validate_expression { objectName: 'showcase_task', expression: 'record.estimate_hours * 1.1', site: 'formula' } — a sound formula", + "call it with a BARE field ref in formula site ('estimate_hours * 1.1') and with an unknown field ('record.estimate_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 the flow_condition site with bare fields ('estimate_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" ], @@ -424,7 +438,7 @@ { "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", + "verify": "response has ok:true, inferredType present, inScope.fields includes estimate_hours", "evidence": "the response" }, { @@ -436,7 +450,7 @@ { "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", + "verify": "'estimate_hours > 8' ok under flow_condition, error under formula", "evidence": "the paired responses" }, { @@ -457,7 +471,8 @@ ], "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": 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-10-16", "change": "the probe field is showcase_task.estimate_hours, not est_hours (#23878 checklist-accuracy finding): est_hours answers ok:false with unknown-field est_hours, so every step built on it measured a typo, not the validator. Re-run on main c91af8ed6 over MCP HTTP: record.estimate_hours * 1.1 ok:true inferredType number, inScope.fields includes estimate_hours; the bare formula ref is a bare-reference error; estimate_hourz is unknown-field with did-you-mean estimate_hours; record.title * 2 is a type-mismatch WARNING; 'estimate_hours > 8' is clean under flow_condition and a bare-reference error under formula; an unknown object and sys_user are refused", "ref": "#24036" } ] }, { @@ -465,13 +480,13 @@ "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, + "revision": 2, "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)", + "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) — or the same three authored at runtime with PUT /api/v1/meta/skill/NAME on stock showcase (admin; 200), which the projection serves at once", "MCP HTTP reachable with an osk_ key (see ai.mcp-http-surface)" ], "knownGaps": [ @@ -484,7 +499,7 @@ "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" + "flip qa_prompt_off to active:true and make the change LIVE before re-listing. File-authored skill under os dev: save the source while the dev server watches it (it rebuilds and restarts: '✓ server restarted — the new build is live'), or run objectstack build (or start with --compile) before restarting. A bare restart serves the STALE dist (the boot warns 'dist/objectstack.json is OLDER than your sources'), and on that boot GET /api/v1/meta/skill/NAME reads the old definition while the MCP projection already follows the source, so the two surfaces disagree. Runtime-authored skill: PUT /api/v1/meta/skill/qa_prompt_off with active:true is live at once. Confirm GET /api/v1/meta/skill/qa_prompt_off reads active:true, then re-list" ], "acceptance": [ { @@ -531,7 +546,8 @@ "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" } + { "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" }, + { "revision": 2, "date": "2026-10-17", "change": "steps[5] said 'reload metadata' without naming what makes a source edit live (checklist-accuracy finding, #23878 close-out): os dev serves the built dist, so a bare restart re-serves the old definition at /meta while the MCP projection follows the source. The step now names the rebuild routes (the watcher's rebuild + restart, objectstack build, --compile), the stale-boot warning, the runtime PUT route, and a /meta active:true check before re-listing; the fixture names the runtime-authored route. Re-run on main c91af8ed6. Runtime route on stock showcase: three skills PUT (200 each); initialize declares the prompts capability; prompts/list carries qa_prompt_full with title and description, not qa_prompt_bare or qa_prompt_off; prompts/get qa_prompt_full returns the authored instructions verbatim; prompts/get qa_prompt_bare and an unknown name answer -32602; PUT qa_prompt_off active:true and the next list carries it. File route on a scratch copy of the showcase under os dev: list carried only qa_prompt_full; saving active:true rebuilt and restarted the server and the list then carried qa_prompt_off (/meta active true); flipping it back to false with the server stopped and restarting without a build printed the stale-dist warning, and on that boot prompts/list omitted qa_prompt_off while GET /api/v1/meta/skill/qa_prompt_off still read active true. Clause 4: packages/mcp src/skill-prompts.test.ts 12 passed", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 550f8b941c6..129ef961c5e 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -9,7 +9,7 @@ "title": "transactionalBatch capability bit matches the mounted /batch behavior — atomicity, rollback codes, size cap", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": [ @@ -18,13 +18,13 @@ "fixtures": { "app": "showcase", "requires": [ - "two batch-writable objects (showcase_private_note and showcase_task both accept baseline member creates)", + "two batch-writable objects: showcase_private_note (title is enough) and showcase_task — a showcase_task create needs its required `project` lookup (a seeded showcase_project id), or it answers 400 VALIDATION_FAILED 'Project is required'", "default maxBatchSize (200 unless RestServerConfig.batch overrides)" ] }, "steps": [ "boot showcase isolated; GET /api/v1/discovery and read capabilities.transactionalBatch.enabled — on the wire each capability is a CapabilityDescriptor object { enabled, features?, description? } (discovery.zod.ts), never a bare boolean. The @objectstack/client getter is the exception: it FLATTENS each descriptor to a real boolean, so client.capabilities.transactionalBatch is a boolean and the two reads are spelled differently on purpose", - "POST /api/v1/batch with a well-formed cross-object atomic batch: operations = [create showcase_private_note {title}, create showcase_task {title}] and a TOP-LEVEL atomic: true; verify both rows exist afterwards. ⚠️ CrossObjectBatchRequestSchema declares operations + a top-level atomic and no options key at all — an options wrapper is silently STRIPPED by Zod, so the request runs with atomic defaulting to true", + "POST /api/v1/batch with a well-formed cross-object atomic batch: operations = [create showcase_private_note {title}, create showcase_task {title, project: }] and a TOP-LEVEL atomic: true; verify both rows exist afterwards. ⚠️ CrossObjectBatchRequestSchema declares operations + a top-level atomic and no options key at all — an options wrapper is silently STRIPPED by Zod, so the request runs with atomic defaulting to true", "POST /api/v1/batch with the same operations and a TOP-LEVEL atomic: false; capture status + error code. ⛔ Spelled as options {atomic: false} the key is stripped, atomic defaults to true, and the batch simply SUCCEEDS — the clause below then reads as a product failure when nothing failed", "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", @@ -99,6 +99,12 @@ "date": "2026-10-04", "change": "A5 / step 6 re-pointed: \"neither blocks the other\" holds only with options.continueOnError: true; the default (false) stops at the first failure with a NOT_ATTEMPTED tail and nothing rolled back. The clause now names the flag for the mixed-outcome leg and asserts the default stop as a second leg (batch.zod.ts BatchOptionsSchema.continueOnError and the BatchOperationResult errors describe; protocol.ts continueOnError branch). Assertion defect: the clause expected the mixed outcome from the default. Source 744b8f5f5 (#7581)", "ref": "#21735" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "the fixture line said showcase_task accepts a baseline create with only a title (#23878 checklist-accuracy finding); its project lookup is required. Re-measured on main c91af8ed6: a task create without project answers 400 VALIDATION_FAILED fields[project] required, alone or inside an atomic /batch, where the whole batch is refused and the sibling note does not persist; with a project the atomic batch answers 200. The fixture line and step 2's operations now carry the project; the rollback clause is unchanged and its re-run passed", + "ref": "#24036" } ] }, @@ -172,14 +178,14 @@ "title": "Removed/retired authoring keys fail at parse/build with located guidance", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "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; run ObjectSchema.create / os build where the key is config-side. The retired filter operator is DIFFERENT and is driven at runtime: author a stored filter carrying it (e.g. { name: { $regex: 'acme' } } in a view or widget filter) and confirm parse/build stay GREEN (FilterConditionSchema is an open record keyed by field name — no tombstone can live on it); then send the same predicate as the where of a query request on the running stack (e.g. POST /api/v1/data//query) and capture the 400 INVALID_FILTER refusal; then run `os migrate meta --from 16` and locate the filter-regex-options-retired advisory", "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", + "the documented-strip control: parse an UNKNOWN key on the RestServerConfig `routes` sub-object — RestServerConfigSchema.safeParse({ routes: { someUnknownKey: 1 } }) (or RouteGenerationConfigSchema directly) — and confirm it parses clean with the key gone (routes reads back {}). The behavior is declared in packages/spec/src/api/rest-server.zod.ts: RouteGenerationConfigSchema's docblock states the schemas are not `.strict()`, which is exactly why its retired keys carry tombstones. No spec docblock declares a 'pure display annotation' that is stripped by design — that example is not the control; this documented non-strict sub-object is", "record which registry each sampled key came from" ], "acceptance": [ @@ -196,9 +202,9 @@ "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", + "clause": "the documented-strip control still parses clean AND its documentation says so: an unknown key on RestServerConfig.routes strips silently, as RouteGenerationConfigSchema's docblock declares (the schemas are not `.strict()`), while that sub-object's RETIRED keys (includeObjects …) are refused with their prescriptions — the strip stays deliberate and documented, not accidental", "oracle": "build", - "verify": "parse succeeds; cite the doc/schema comment declaring the strip", + "verify": "RestServerConfigSchema.safeParse({ routes: { someUnknownKey: 1 } }) succeeds with routes {}; cite the RouteGenerationConfigSchema docblock (packages/spec/src/api/rest-server.zod.ts); and RouteGenerationConfigSchema.safeParse({ includeObjects: ['x'] }) fails with the retired-key prescription, so the same sub-object shows both behaviors", "evidence": "parse result + doc cite" }, { @@ -209,7 +215,7 @@ } ], "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)" + "also run the documented-strip control (step 4: an unknown key on RestServerConfig.routes, a sub-object the spec documents as non-strict) 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", @@ -238,6 +244,12 @@ "date": "2026-10-04", "change": "A1 / step 2 re-pointed: the retired-filter-operator registry is not a parse/build gate — FilterConditionSchema is an open record, so a stored $regex / $options parses and builds green; it is refused at runtime with 400 INVALID_FILTER and the RETIRED_FILTER_OPERATORS prescription, and `os migrate meta` lists it (semantic entry filter-regex-options-retired). The clause now scores that registry on those terms. Assertion defect. Source filter.zod.ts RETIRED_FILTER_OPERATORS, 85d95e7d92 (#5701 / #5752)", "ref": "#21735" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "the strip-by-design leg named no concrete key, and no spec docblock declares a 'pure display annotation' that is stripped by design (#23878 checklist-accuracy finding; searched on main c91af8ed6). The leg now names the documented control the run used: an unknown key on RestServerConfig.routes parses clean to routes {} because RouteGenerationConfigSchema's docblock declares the schemas non-strict, and the same sub-object's retired key includeObjects is refused with its prescription. Re-run against the built @objectstack/spec: safeParse success with {} for the unknown key, failure naming '`routes.includeObjects` was removed …' for the tombstone", + "ref": "#24036" } ] }, @@ -246,7 +258,7 @@ "title": "Server-Timing: the per-request X-OS-Debug-Timing path discloses spans to admins only, OS_SERVER_TIMING global mode to everyone, per-query detail admin-only always — on the server os dev actually runs", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": [ @@ -262,9 +274,9 @@ ], "acceptance": [ { - "clause": "with global mode OFF, the per-request path discloses to an admin: the admin request carrying X-OS-Debug-Timing carries the Server-Timing spans (total/auth/db/hooks/serialize) on the live hono server — the dispatcher opens the disclosure gate only after resolving an admin/service identity", + "clause": "with global mode OFF, the per-request path discloses to an admin: the admin request carrying X-OS-Debug-Timing carries the Server-Timing spans (parse, db, serialize, handler, total — the spans the hono adapter and plugin emit; there is no auth or hooks span) on the live hono server — the dispatcher opens the disclosure gate only after resolving an admin/service identity", "oracle": "network", - "verify": "step-2 response has a Server-Timing header naming the span set", + "verify": "step-2 response has a Server-Timing header naming the span set: parse, db, serialize, handler and total (packages/plugins/plugin-hono-server/src/adapter.ts parse/serialize/handler, hono-plugin.ts total and the db details)", "evidence": "header dump" }, { @@ -316,6 +328,12 @@ "date": "2026-10-04", "change": "A1-A3 and steps 1/4 re-pointed: the modes were swapped. With OS_SERVER_TIMING off, X-OS-Debug-Timing is the admin-only per-request gate (admin gets spans, non-admin none); OS_SERVER_TIMING=true is global mode and sends spans to EVERY caller, with only X-OS-Debug-Timing-Detail admin-only (hono-plugin.ts: the serverTiming option doc and the Server-Timing middleware's globalTiming / gate.privileged branches). Title and negative follow; source cites the file. Assertion defect: the clause expected the env flag to be a second admin gate. Source #3163 / #3169", "ref": "#21735" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "clause 1's span list total/auth/db/hooks/serialize was stale (#23878 checklist-accuracy finding): no auth or hooks span exists in source. Re-measured on main c91af8ed6 with X-OS-Debug-Timing: 1 and global mode off: the admin's Server-Timing carries parse, db (N queries), serialize, handler and total; the member's identical request carries no Server-Timing header at all (clause 2, re-run); X-OS-Debug-Timing: json adds X-OS-Debug-Timing-Detail for the admin", + "ref": "#24036" } ] }, @@ -484,7 +502,7 @@ "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": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -505,7 +523,7 @@ "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)", + "batch: POST /api/v1/batch {\"operations\": [{\"object\": \"showcase_private_note\", \"data\": {\"title\": \"probe\"}}], \"atomic\": false} — atomic at the TOP LEVEL — the registered-extension-code sample (400 BATCH_NOT_ATOMIC). ⛔ Not options {\"atomic\": false}: CrossObjectBatchRequestSchema declares operations + a top-level atomic and no options key, so the wrapper is stripped, atomic defaults to true and the batch SUCCEEDS (200, a row created) — the same spelling api-backend.batch-transactional-discovery already warns about", "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 ErrorCode (error-code-ledger.zod.ts — the canonical ADR-0112 D4 union, StandardErrorCode.options ∪ REGISTERED_ERROR_CODES), envelope shape per ErrorResponseSchema/EnhancedApiErrorSchema (noting the REST flat dialect where it is the declared shape), fields[] entries against FieldErrorCode" ], @@ -587,6 +605,12 @@ "date": "2026-08-21", "change": "named the seeded demo personas in fixtures.requires. The 17.1.0 sweep recorded this item's 403 family as unreachable on stock fixtures and scored the item partial, having assumed a runner gets only the dev admin; the filer withdrew that in the same wave after driving Mei to a real 403 PERMISSION_DENIED (6/6 families, superseding #10233 on that point). The personas were always there — the item just never said so, which is what made the wrong assumption cheap. Requirement text now carries both persona emails, the well-known password constant and its source file, so the next runner does not repeat it (#10236 A5)", "ref": "#10236" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "step 6 sent the BATCH_NOT_ATOMIC sample as options {atomic:false} (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: that body answers 200 and creates the row, because batch.zod strips the options wrapper; a TOP-LEVEL atomic:false answers 400 BATCH_NOT_ATOMIC ('Cross-object batch is always atomic; use POST /data/:object/batch …'), a code ErrorCode carries. The step now uses the top-level spelling; the clauses are unchanged", + "ref": "#24036" } ] }, @@ -595,7 +619,7 @@ "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, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -605,17 +629,17 @@ "fixtures": { "app": "showcase", "requires": [ - "showcase_private_note (baseline create/edit — the bulk probe object)", + "showcase_private_note (baseline create/read/edit — the bulk probe object; the baseline member holds NO allowDelete on it, so the delete legs run as admin)", "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", + "boot showcase isolated; as the member POST /api/v1/data/showcase_private_note/createMany with a BARE JSON ARRAY of 3 notes with unique-marker titles (the body is the array; a {records} wrapper answers 400 VALIDATION_FAILED); 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 the member POST /api/v1/data/showcase_private_note/updateMany {\"records\": [{\"id\": \"\", \"data\": {…}}, …]} naming ONLY the marker rows' ids — updateMany is id-based (UpdateManyRequestSchema); a {where, data} body answers 400 VALIDATION_FAILED naming `records`; re-read", + "as the member POST updateMany naming ids that match nothing the member may touch: a nonexistent id, and the id of another user's private note (invisible to the member); capture the per-row results, then re-read the foreign note system-side. Also send {\"records\": []}", "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)", + "deleteMany is id-based too ({\"ids\": [...]}; a {where} body answers 400 naming `ids`). As the member, POST deleteMany on two of its own marker notes and capture the refusal — the member baseline holds no allowDelete on showcase_private_note (row 0 PERMISSION_DENIED, the rest NOT_ATTEMPTED); then as admin POST deleteMany with the marker ids; re-read; then re-issue the same deleteMany (the ids are gone now)", "throughout: capture per-row result arrays and any ERR_BULK_RESULT_MISMATCH appearance" ], "acceptance": [ @@ -626,15 +650,15 @@ "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)", + "clause": "updateMany applies exactly to the rows it names: 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)", + "clause": "a write that names nothing the caller may touch is a calm per-row no-op, never a wider write: updateMany naming a nonexistent id, or another user's invisible note, answers 2xx with that row failed RECORD_NOT_FOUND (the same code for both — no existence oracle) and the foreign row unchanged; an empty records list answers 2xx with total 0; a repeated deleteMany reports its now-missing ids RECORD_NOT_FOUND (the #2982 failure shape was bulk writes escaping their scope — an id-based door cannot match more rows than it names)", "oracle": "api", - "verify": "step-4/6 responses report zero affected; a follow-up unfiltered system read shows no unexpected mutations/deletions", + "verify": "step-4/6 responses: per-row RECORD_NOT_FOUND for the nonexistent and the foreign id, success false, succeeded 0; the foreign note re-reads unchanged system-side; the empty list reports total 0; the repeated deleteMany's first row RECORD_NOT_FOUND", "evidence": "responses + control read" }, { @@ -644,9 +668,9 @@ "evidence": "response + count" }, { - "clause": "deleteMany removes exactly the scoped set and the deletion persists", + "clause": "deleteMany removes exactly the ids it names and the deletion persists — and it is gated per row on delete permission: the member (no allowDelete) is refused row by row, the admin's run succeeds", "oracle": "api", - "verify": "post-delete GET finds zero marker rows; the member's other notes remain", + "verify": "the member's deleteMany: row 0 PERMISSION_DENIED, later rows NOT_ATTEMPTED, nothing deleted; the admin's deleteMany: every named row success, a post-delete GET finds those rows gone and the member's other notes remain", "evidence": "re-reads" }, { @@ -657,7 +681,7 @@ } ], "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" + "run the updateMany probe as a persona with rows it CANNOT see — name another user's note id — and confirm the invisible row is neither counted nor mutated and answers the same RECORD_NOT_FOUND as a nonexistent id (bulk writes are RLS-scoped, #2982) — a success on that row is a security FAIL, not a bookkeeping quirk" ], "variants": [ "door:createMany", @@ -685,6 +709,12 @@ "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" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "re-pointed to the id-based bulk doors (#23878 checklist-accuracy findings, re-measured on main c91af8ed6). updateMany takes {records:[{id,data}]} and deleteMany {ids:[…]} (a where body answers 400 VALIDATION_FAILED naming records / ids), and createMany a bare array. The where-clause steps and the 'empty match' clause are rewritten for that contract: a nonexistent id and another user's invisible note both answer per-row RECORD_NOT_FOUND with the foreign row unchanged, and records [] answers total 0 — the clause still fails if a row the caller cannot see is written. The baseline member holds no allowDelete on showcase_private_note (member deleteMany: PERMISSION_DENIED then NOT_ATTEMPTED; single DELETE 403), so the delete legs name the member's refusal and run the deletion as admin. BATCH_TOO_LARGE at 201 records re-measured: 400, zero rows", + "ref": "#24036" } ] }, @@ -693,7 +723,7 @@ "title": "Every route family across all ELEVEN route ledgers — REST, dispatcher, auth, storage/i18n/settings/datasource services, metadata HMR, trigger-api, cloud-connection, console static — is actually mounted on the live server — no route that exists only in unit tests", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": [ @@ -707,14 +737,14 @@ ] }, "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)", + "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, 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 two service families that were UNLEDGERED when this item was first written and are ledgered at head — the run must NOT file the old unledgered finding: as admin GET /api/settings (settings-route-ledger.ts, #7526 — note the /api/settings base, NOT /api/v1) and GET /api/v1/datasources (datasource-route-ledger.ts, #7744). For the datasource family probe BOTH sides of the #9391/#9593 floor: anonymous GET /api/v1/datasources/drivers → 401 UNAUTHENTICATED (the 401 proves the route is mounted AND the floor holds — it is NOT the routing 404, and NOT the pre-#9391 'always-available static catalog'), then the same GET as admin (manage_platform_settings via admin_full_access) → 200 driver catalog; GET /api/v1/datasources as admin → 200 (or 503 naming the unwired service, which still proves the mount)", "fire the RAW-APP-mounted families — these registrars mount via getRawApp() and are structurally invisible to IHttpServer.getMountedRoutes(), so the #7526 parity dogfood gate CANNOT observe them and this item's live probes are their only live-HTTP observation (each ledger header says so): metadata HMR GET /api/v1/dev/metadata-events (metadata-route-ledger.ts — an SSE stream: read only the status line + headers with a short timeout, never wait for the stream to end; mounted ONLY under a literal NODE_ENV=development, which os dev sets — #12140/#5673) and POST /api/v1/dev/metadata-events; trigger-api POST /api/v1/automation/hooks/no_such_flow/bogus (trigger-api-route-ledger.ts — the handler answers a structured JSON refusal, distinguishable from the routing 404); cloud-connection GET /api/v1/runtime/config (anonymous 200 by design) and GET /api/v1/cloud-connection/status (cloud-connection-route-ledger.ts — 16 rows across four registrar families; sample only for MOUNT parity here, platform-core owns the marketplace lifecycle depth); console static GET /_console (console-route-ledger.ts — disposition static-asset)", "fire the dispatcher meta state route: GET /api/v1/meta/object/showcase_task/state/status?from=in_review, the same with ?from omitted, and GET /api/v1/meta/object/not_a_real_object/state/status as the 404 control", - "fire one deliberately-unmounted path (GET /api/v1/definitely-not-a-route) as the 404 control", + "fire one deliberately-unmounted path (GET /api/v1/definitely-not-a-route) as the 404 control; GET /api/v1/reports answers that same routing-404 shape — the reports family was RETIRED (#20102: its eight saved-report rows left the REST ledger with their routes and the SDK namespace), so it is no longer a variant", "compare GET /api/v1/discovery capability bits against the families that answered (search/export/transactionalBatch at minimum)" ], "acceptance": [ @@ -749,7 +779,7 @@ "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", + "clause": "the sweep reaches the OTHER ledgers, not just the 18 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" @@ -802,7 +832,6 @@ "security-explain", "record-shares", "sharing-rules", - "reports", "approvals", "email", "analytics", @@ -830,7 +859,7 @@ "absence-inference" ], "source": [ - "packages/rest/src/rest-route-ledger.ts#REST_ROUTE_LEDGER (variant source — the 19 REST families — re-pointed #18104: the bare word REST is carried in this file only as a substring of longer tokens)", + "packages/rest/src/rest-route-ledger.ts#REST_ROUTE_LEDGER (variant source — the 18 REST families — re-pointed #18104: the bare word REST is carried in this file only as a substring of longer tokens)", "packages/rest/src/rest-route-ledger.conformance.test.ts", "packages/runtime/src/route-ledger.ts#ROUTE_LEDGER (dispatcher ledger — share-links/keys/notifications/suggested-bindings/i18n/analytics families + the meta.getLegalNextStates state route — re-pointed #18104: getLegalNextStates is a CLIENT METHOD name, carried here only inside a dotted string value)", "packages/plugins/plugin-auth/src/auth-route-ledger.ts#AUTH_ROUTE_LEDGER (AUTH_ROUTE_LEDGER — the enumerated better-auth table, #3656; raw-app mount)", @@ -865,6 +894,12 @@ "date": "2026-08-30", "change": "de-staled against the head ledger universe, re-derived from source (find '*route-ledger.ts' — ELEVEN files). Three revision-2 claims had gone false and a run following them files false findings: (a) the 'unledgered:/api/settings' and 'unledgered:/api/v1/datasources' variants/clauses/sources — both families are ledgered at head (settings-route-ledger.ts #7526; datasource-route-ledger.ts + conformance test #7744) and the tranche-3 discipline-gap finding is retired; (b) step 5's 'always-available static catalog' for GET /datasources/drivers — since #9391/#9593 ALL eleven datasource-admin routes (catalog included) answer 401/403 without manage_platform_settings, so the step now drives all three personas and pins the 401-means-mounted reading; (c) the PENDING-GAPS.md source refs — that file was deleted after the 2026-08 sweep (FOLLOW-UPS.md records it). Extended the sweep to the five raw-app ledgers the revision-2 universe missed (metadata HMR with its #12140 dev posture, trigger-api #11863, cloud-connection #11882 with 16 rows, console static #11882 — each header states getMountedRoutes() cannot see them, making this item their only live-HTTP observation); added the corresponding variants and the absence-inference trap", "ref": "#sweep-2026-08-30" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "the REST ledger carries 18 families, not 19: 'reports' was RETIRED (#20102 — packages/rest/src/rest-route-ledger.ts keeps only a RETIRED comment where its rows were), so the variant is dropped, the family list in step 1 no longer names it, and clause 6 and source[0] say 18 (#23878 checklist-accuracy finding). Re-run on main c91af8ed6: one route per remaining REST family answered non-routing-404 (eighteen of eighteen), GET /api/v1/reports answered the same 404 ENDPOINT_NOT_FOUND as the GET /api/v1/definitely-not-a-route control, and the tree still holds eleven *route-ledger.ts files", + "ref": "#24036" } ] }, @@ -873,7 +908,7 @@ "title": "Metadata-authored `api` endpoints mount as real URLs with their declared auth/cache policy", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -883,7 +918,7 @@ "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" + "the three live declarative endpoints in examples/app-showcase/src/system/apis/index.ts — a GET task feed at /api/v1/apps/showcase/tasks (object_operation, showcase_task_feed) and two flow-delegating endpoints, showcase_inquiry_purge_api (/api/v1/apps/showcase/inquiries/purge) and showcase_recalc_estimates_api (/api/v1/apps/showcase/recalc); `os build` reports them as '3 APIs' — ADR-0121 / #5040" ] }, "variants": [ @@ -937,7 +972,7 @@ "dispatcher-vs-hono-route" ], "source": [ - "examples/app-showcase/src/system/apis/index.ts (the two live endpoints)", + "examples/app-showcase/src/system/apis/index.ts (the three live endpoints)", "ADR-0121 / #5040 (declarative endpoint E-series)", "packages/runtime notFound-fallback mount seam", "packages/spec/src/api/endpoint-publish-gate.ts#validateApiEndpointDeclarations (the authoring refusal of script / proxy targets, run by the stack schema) + packages/spec/src/kernel/metadata-plugin.zod.ts (api is code-only: allowRuntimeCreate false, #5488) + packages/runtime/src/endpoint-executor.ts#planEndpointTarget (the runtime's 501 second door)" @@ -954,6 +989,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21720 parked on #22017 (row 13), re-pointed clause. A3 asserted a live 501 from a script / proxy endpoint authored in a scratch package, and the run found no way to author one without editing source. No public door produces one: api is code-only (#5488), and the stack schema refuses script / proxy targets (validateApiEndpointDeclarations, packages/spec/src/api/endpoint-publish-gate.ts). The runtime's 501 is a second door for a declaration that reaches the store another way, pinned by endpoint-executor.test.ts. A3 now asserts the authoring refusal (oracle build) and cites the pins; step 4, variant 5, `automated` and source follow", "ref": "#22017" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "the fixture text named two live declarative endpoints while the showcase declares three and os build reports '3 APIs' (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: apis/index.ts declares showcase_task_feed (object_operation), showcase_inquiry_purge_api and showcase_recalc_estimates_api (both flow). Wording only, no re-run owed; the feed's live answers still hold (authed 200 with Cache-Control: private, max-age=30; anonymous 401 UNAUTHENTICATED; an unmatched /apps path 404)", + "ref": "#24036" } ] }, @@ -962,7 +1003,7 @@ "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": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -979,7 +1020,7 @@ }, "steps": [ "boot showcase isolated; sign in as admin; choose a scratch id com.example.qa-pkg-lifecycle-probe", - "POST /api/v1/packages with { manifest: { id: 'com.example.qa-pkg-lifecycle-probe', name: 'QA Probe', version: '1.0.0', scope: 'custom', type: 'app' } }; capture status + body; then GET /api/v1/packages/com.example.qa-pkg-lifecycle-probe to confirm it persisted (the dispatcher install lands in BOTH the in-memory registry and durable sys_packages)", + "POST /api/v1/packages with { manifest: { id: 'com.example.qa-pkg-lifecycle-probe', name: 'QA Probe', version: '1.0.0', scope: 'project', type: 'app' } }; capture status + body; then GET /api/v1/packages/com.example.qa-pkg-lifecycle-probe to confirm it persisted (the dispatcher install lands in BOTH the in-memory registry and durable sys_packages) (scope must be one of cloud | system | project — 'custom' answers 400 VALIDATION_ERROR naming manifest.scope — and the install derives a namespace from the id)", "POST the SAME manifest again with NO overwrite flag; capture status + body", "PATCH /api/v1/packages/com.example.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", @@ -1007,9 +1048,9 @@ "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", + "clause": "PATCH validates its inputs: an empty name, a non-semver version and a nothing-to-update body each answer 400 VALIDATION_ERROR — a caller-fixable 400, never a 500", "oracle": "api", - "verify": "the three step-5 responses are 400 with the located messages", + "verify": "the three step-5 responses are each status 400 with error.code VALIDATION_ERROR (the message wording is not pinned: it names the field and, for the version, the SemVer 2.0.0 form)", "evidence": "the three rejections" }, { @@ -1052,6 +1093,12 @@ "date": "2026-09-20", "change": "the scratch probe id these steps POST is rewritten to a reverse-domain spelling (com.example.qa-pkg-lifecycle-probe) — `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, so the underscored spellings this item used to name are refused by every door but the HTTP install door, and a checklist that teaches one is the trap the pattern was added to close. Steps, legs and expected verdicts are otherwise unchanged", "ref": "#18319" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "step 2's manifest used scope 'custom', which POST /api/v1/packages refuses 400 VALIDATION_ERROR ('manifest.scope: Invalid option: expected one of cloud|system|project'), so the install the rest of the item builds on never happened (#23878 checklist-accuracy finding). It now uses scope 'project'. Clause 4 pinned two message sentences, one of which had changed ('version must be a SemVer 2.0.0 string …'); it now scores status 400 and code VALIDATION_ERROR for all three bodies. Re-run on main c91af8ed6: 201 install, 409 RESOURCE_CONFLICT duplicate, partial PATCH 200, the three malformed PATCHes 400 VALIDATION_ERROR, overwrite re-install 201 with exactly one row", + "ref": "#24036" } ] }, @@ -1368,7 +1415,7 @@ "title": "A retired metadata DEFINITION (whole schema type withdrawn) refuses at every authoring door — distinct from a retired key, and the door that must not fall through to 'unknown type, ignored'", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "build", "personas": [ @@ -1381,16 +1428,16 @@ "a scratch authoring source tree (os build target) plus a live boot for the metadata-write door" ], "knownGaps": [ - "Some retired defs were removed by ADR-0087 Route 3 ('no tombstone, no D2 conversion — this table plus the D3 semantic entry are the declaration', e.g. data/ExternalDataSource per #8075). For those, the correct behavior may be the GENERIC unknown-type refusal rather than a def-specific prescription. The run must read each sampled entry's route BEFORE deciding what a pass looks like — scoring a Route-3 def against a prescription it was never given is a false fail, and scoring it against 'anything 4xx' is a false pass." + "Some retired defs were removed by ADR-0087 Route 3 ('no tombstone, no D2 conversion — this table plus the D3 semantic entry are the declaration', e.g. data/ExternalDataSource per #8075). For those, the correct behavior may be the GENERIC unknown-type refusal rather than a def-specific prescription. The run must read each sampled entry's route BEFORE deciding what a pass looks like — scoring a Route-3 def against a prescription it was never given is a false fail, and scoring it against 'anything 4xx' is a false pass. Measured on main c91af8ed6: a zero-consumer def's only author-reachable door is the import of its schema export, refused generically (no prescription), so it scores as Route-3-shaped whatever route its entry names; the def-specific prescription, where one exists, is surfaced by its CARRIER's retired-key tombstone" ] }, "steps": [ "enumerate packages/spec/src/migrations/entries/retired-defs/ and record the count and the version prefixes present (the registry currently carries entries at both the current major and the next)", "sample at least four entries spanning BOTH version prefixes and at least three different domains (e.g. data/, system/, identity/, ui/)", - "for each sampled def, read its entry file and record which ADR-0087 route retired it and whether a prescription was authored", - "author a stack that declares metadata of each sampled retired type and run os build", - "attempt the same through the runtime metadata-write door (POST/PUT /meta/) against a live boot", - "as a control, author metadata of a type that was NEVER declared (a typo'd/invented type name) and compare its refusal to the retired-def refusals" + "for each sampled def, read its entry file and record which ADR-0087 route retired it, whether a prescription was authored, and its CARRIER: the authored key that embedded it, retired as a retiredKey() tombstone (e.g. manifest.loading for kernel/PluginLoadingConfig and its closure; agent.lifecycle for automation/StateMachine and its family), or none — most entries are schema-export definitions with zero consumers: no metadata-type binding, no stack collection, no embedding (e.g. data/ExternalDataSource, automation/ScheduleState). Sample at least one def of each kind", + "build door, per sampled def, each in its own scratch stack run through os build: for a def WITH a carrier, author the carrier key (e.g. manifest.loading: { strategy: 'lazy' }; an agent carrying lifecycle) — the refusal is the carrier's tombstone. For a def WITHOUT one there is no metadata of that type to author ('a stack that declares metadata of the retired type' could only be an invented key, which is the control below), so drive the door it actually had: import its Zod export from its @objectstack/spec subpath in the config (e.g. ExternalDataSourceSchema from '@objectstack/spec/data') and confirm json-schema/DOMAIN/NAME.json is absent from packages/spec/json-schema", + "runtime door against a live boot: PUT /api/v1/meta/TYPE/NAME carrying the carrier key where its type is runtime-writable, and record a code-only type's up-front refusal where it is not (agent is code-only); for a zero-consumer def, PUT a type name spelled after it (e.g. external_data_source) — no type was ever bound, so the expected answer is the same refusal as the control", + "as a control, author an invented top-level stack key (e.g. qaInventedFamily) at the build door and PUT an invented type name at the runtime door, and compare their refusals to the retired-def refusals" ], "acceptance": [ { @@ -1438,6 +1485,12 @@ "date": "2026-08-17", "change": "new — api-backend.enforce-or-remove-authoring-gates samples the retired-KEY registries and explicitly requires three key-level surfaces, but nothing exercised the retired-DEF registry, which is a materially different door: a withdrawn KEY loses one property, a withdrawn DEFINITION loses an entire metadata family, and the fall-through failure mode ('unknown type, ignored') is different from the strip-mode failure the key item guards. The registry has grown substantially since the ledger was built and now carries entries for the next major as well as the current one", "ref": "#9299" + }, + { + "revision": 2, + "date": "2026-10-17", + "change": "steps re-pointed (checklist-accuracy finding, #23878 close-out): RETIRED_DEFS entries are schema-export definitions, most with no metadata-type binding or stack collection, so 'a stack that declares metadata of each sampled retired type' could only be an invented key. Steps 3–6 now sample by carrier (an authored key retired as a tombstone, or none), drive the carrier key or the schema-export import at os build, name the runtime type each reaches, and make the control an invented stack key and an invented type; knownGaps[0] records the reading. No clause changed. Re-run with os build on a minimal scratch stack (baseline exit 0): 17 data/ExternalDataSource (zero consumers) import → exit 2 \"The requested module '@objectstack/spec/data' does not provide an export named 'ExternalDataSourceSchema'\"; 18 automation/ScheduleState (zero consumers) import → exit 2, the same generic shape; 17 kernel/PluginLoadingConfig via manifest.loading → exit 2 'manifest.loading was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block …'; 18 automation/StateMachine via agent.lifecycle → exit 2 'agent.lifecycle was removed in @objectstack/spec 17.7.0 … Delete the key …'; control qaInventedFamily → exit 2 'Unrecognized key(s) on this stack definition'. The carrier refusals name the retirement and differ from the control; the zero-consumer ones are generic, which knownGaps[0] scores as correct. json-schema/data/ExternalDataSource.json, automation/ScheduleState.json and automation/StateMachine.json are absent. Runtime door on stock showcase: PUT /meta/agent/NAME with lifecycle → 403 'Metadata type agent is code-only …'; PUT /meta/external_data_source/NAME and the invented control → the same 400 \"… is not a metadata type\"", + "ref": "#24036" } ] }, @@ -1702,12 +1755,12 @@ "title": "Server-side aggregation contract: every AggregationFunction × DateGranularity gives known answers — ruled empty-set values, ISO-Monday week buckets, retired array_agg/string_agg refuse with prescriptions, and aggregate stays secured (engine-only, never raw driver)", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ "admin (known-answer runs over the seeded baseline)", - "a restricted non-admin persona for the secured-aggregate leg (Mei Phone phone.demo@example.com or Ada Auditor auditor.demo@example.com, DEMO_PERSONA_PASSWORD 'showcase123' — same seeded personas api-backend.error-envelope-ledger names)" + "a restricted non-admin persona for the secured-aggregate leg whose visible row set provably DIFFERS from the admin's: the search:qa-contributor-bound-member persona (a contributor-bound member who reads only its own showcase_invoice rows — give it one of its own, on an ACTIVE account). ⛔ Not the seeded demo personas: Mei Phone is refused showcase_invoice at the object gate (403 PERMISSION_DENIED, no answer to compare) and Ada Auditor holds viewAllRecords (her counts equal the admin's), so neither can tell a secured aggregate from a raw one" ], "fixtures": { "app": "showcase", @@ -1715,6 +1768,10 @@ "seeded showcase_account rows (numeric annual_revenue, date signed_on, select status/sales_region) — the same known-answer dataset api-backend.query-contract-matrix uses; expected values are ALWAYS computed locally from an unfiltered baseline read, never hardcoded", "a boolean field for the #11152 min/max-over-boolean leg (showcase_field_zoo carries checkbox-typed fields)" ], + "provisioning": { + "use": "search:qa-contributor-bound-member", + "why": "supplies acceptance[4]'s restricted persona: a contributor-bound member whose showcase_invoice read is its own rows only, so its grouped count must differ from the admin's. Without it the secured-aggregate clause has no discriminating persona on stock (the demo personas are refused at the object gate or read everything) and scores blocked(fixture)" + }, "knownGaps": [ "Stock seed dates may not spread signed_on across enough DISTINCT buckets for every granularity to be answer-discriminating (e.g. all rows in one ISO week proves week-bucketing resolved, not that it bucketed correctly). Same discipline as api-backend.date-range-preset-matrix: record which granularities were answer-discriminating vs merely well-formed; seeding a few rows at known date offsets during the run is the honest close — record it if done.", "Whether a given aggregate is answered by driver pushdown or by the in-memory path (packages/objectql/src/in-memory-aggregation.ts) depends on driver + timezone (tz≠UTC date buckets take the in-memory path per the #1982 note); the two paths must label identically (#3773/#3839 seam). Record which path answered when it is observable (e.g. via the timezone lever), and score the CONTRACT, which is path-independent." @@ -1727,7 +1784,7 @@ "granularity matrix: record wall clock + timezone, then for each of the 5 granularities POST { groupBy: [{ field: 'signed_on', dateGranularity }], aggregations: [{ function: 'count', alias: 'n' }] }; reconcile the returned bucket KEYS and counts against locally computed ISO-8601 buckets (bucketDateValue's own shapes: 'YYYY', 'YYYY-Qn', 'YYYY-MM', 'YYYY-MM-DD', 'YYYY-Wnn' with weeks starting Monday / week 1 = first Thursday); rows with null/unparseable dates must land in ONE real-null-keyed bucket (#3839 — never a '(null)' sentinel string)", "empty-set semantics: (a) a query whose where matches nothing, grouped and ungrouped; (b) a per-aggregation filter (#10576 — aggregations[].filter, SQL FILTER(WHERE …) semantics) that empties one measure while a sibling measure keeps the full bucket. Expected per the platform ruling (emptyGroupValueFor, packages/spec/src/data/aggregation-policy.ts): count/count_distinct/sum → 0, avg/min/max → null — a 0 flattened onto avg (or a null on count) is a FAIL either direction (objectui#3136)", "negatives: aggregations [{ function: 'array_agg', field: 'name', alias: 'x' }] and the string_agg twin → 400 with the RETIREMENT prescriptions (each message ends with the 'os migrate meta --from 16' instruction; only those two spellings get it); an unknown function ('median') → 400 whose issue lists the six legal functions (zod's own enum error, deliberately NOT the retirement text — telling a typo'd author their value 'was removed' would misinform); aggregations naming a nonexistent field → the located protocol refusal (protocol.ts assertAggregationFieldsExist); aggregations as a non-array → the shape refusal with the worked example", - "secured-aggregate leg: run one grouped count as the restricted persona over an object where their visible row set provably differs from the admin's; compute their expectation from THEIR OWN filtered baseline. The rule under test is the one packages/runtime/src/action-execution.ts states for the aggregate door: aggregate MUST run through the ObjectQL engine (RLS/tenant middleware + the FLS aggregate-input gate) — a raw driver.aggregate() evaluates over every row" + "secured-aggregate leg: run one grouped count (count by status on showcase_invoice) as the provisioned contributor-bound member, whose visible row set provably differs from the admin's; compute their expectation from THEIR OWN filtered baseline. The rule under test is the one packages/runtime/src/action-execution.ts states for the aggregate door: aggregate MUST run through the ObjectQL engine (RLS/tenant middleware + the FLS aggregate-input gate) — a raw driver.aggregate() evaluates over every row" ], "acceptance": [ { @@ -1811,6 +1868,12 @@ "date": "2026-08-30", "change": "new — the aggregation vocabulary (6 functions × 5 granularities) had no matrix item: api-backend.query-contract-matrix covers the filter/select/sort axes of the same door and stops short of groupBy/aggregations. Register claims verified against source before authoring: the 6/5 member counts, the empty-set ruling (count/count_distinct/sum→0, avg/min/max→null — the register's 'count→0, avg/min/max→null' was correct but incomplete), the ISO-Monday week rule, the two retirement messages, and the engine-only security rule at action-execution.ts. Only one enumSource pin fits an item, so AggregationFunction carries it and DateGranularity (also a named export, pinnable in principle) is hand-counted in step 1", "ref": "#sweep-2026-08-30" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "the secured-aggregate leg named Mei Phone or Ada Auditor as the restricted persona, and neither gives a differing visible set (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: Mei's grouped count on showcase_invoice answers 403 PERMISSION_DENIED at the object gate and Ada's equals the admin's (viewAllRecords). The item now opts into search:qa-contributor-bound-member, and the re-run on that persona — one own invoice on an active account — gave [{draft: 1}] against the admin's [{draft: 3}, {paid: 5}, {sent: 5}]", + "ref": "#24036" } ] }, @@ -2283,7 +2346,7 @@ "title": "RestServerConfig.metadata is the metadata surface's construction contract: prefix moves eleven mounts and their discovery advertisement, four endpoint switches each gate exactly the face its name states (#15542/#15854), maskObjectFields is the ADR-0106 D8 disclosure gate, and both tombstones refuse at construction", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -2295,7 +2358,7 @@ "app": "showcase", "requires": [ "a stock showcase boot for the default-config half (prefix /meta, cache on, masking ON by default)", - "a persona whose field-level security hides at least one field of a served object — without one, the masking clause scores blocked(fixture), never green", + "a persona whose field-level security hides at least one field of a served object — on stock that is the client_liaison position's showcase_client_liaison set, which withholds showcase_project.budget, .spent and .budget_remaining (readable:false); no stock persona carries FLS on any showcase_account field, so a showcase_account diff is empty for everyone. Without such a persona the masking clause scores blocked(fixture), never green", "a scratch vitest harness in packages/rest for the variant half (construct, registerRoutes(), getRoutes())" ], "knownGaps": [ @@ -2306,7 +2369,7 @@ "steps": [ "boot showcase isolated; as admin fire the default-prefix metadata mounts and capture status: GET /api/v1/meta, /meta/types, /meta/diagnostics, /meta/_drafts, GET /meta/object, GET /meta/object/showcase_task, /meta/object/showcase_task/references, /meta/object/showcase_task/layers, /meta/book//tree; fire the routing-404 control alongside", "GET /api/v1/discovery and read routes.metadata — it must equal the base plus the configured prefix", - "as the restricted persona GET /api/v1/meta/object/showcase_account and diff the served fields against the admin's copy of the same document", + "as the restricted persona (a member bound to the client_liaison position) GET /api/v1/meta/object/showcase_project and diff the served fields against the admin's copy of the same document", "harness: construct with { metadata: { prefix: '/metadata' } } and confirm every metadata mount re-bases and the discovery body advertises the new prefix", "harness: construct with { metadata: { endpoints: { types: false } } } — GET {prefix} and GET {prefix}/types both disappear (one handler, two paths)", "harness: construct with { metadata: { endpoints: { items: false } } } and enumerate what leaves: GET {prefix}/:type and NOTHING else — exactly the one mount its describe() names (#15542 moved the whole-store family off this switch; a run that still sees /diagnostics, /_drafts or POST /_migrate-stored leave with it is the old radius returning)", @@ -2338,7 +2401,7 @@ { "clause": "`maskObjectFields` defaults ON and is a real disclosure gate: a caller who cannot read a field gets a served object schema without that field at all — not its name, label, type, options, formula, visibleWhen, defaultValue or requiredPermissions — and `false` serves the full schema to every authenticated caller (ADR-0106 D8)", "oracle": "api", - "verify": "diff the restricted persona's /meta/object/showcase_account against the admin's on the default config: the hidden field is absent, not blanked. Then in the harness confirm maskObjectFields:false is preserved through normalizeConfig (it must not be re-defaulted to true) and that resolveObjectMasker returns the pass-through masker for it and for any metaType other than 'object'", + "verify": "diff the client_liaison member's /meta/object/showcase_project against the admin's on the default config: budget, spent and budget_remaining are absent, not blanked. Then in the harness confirm maskObjectFields:false is preserved through normalizeConfig (it must not be re-defaulted to true) and that resolveObjectMasker returns the pass-through masker for it and for any metaType other than 'object'", "evidence": "the two served documents + the normalized value" }, { @@ -2405,6 +2468,12 @@ "date": "2026-09-08", "change": "the knownGaps were semantically re-characterised, not merely annotated: what they describe as the harness-scoring workaround is now the PERMANENT method, by ruling (#15543, director seat, summon #17, decision batch #2, 2026-09-07) that the crud / metadata / batch keys stay, are embedder-only, and get their reachability written into the schema docblocks and the liveness ledger instead of being re-derived from the boot paths at every run. A runner reading the old text would be waiting for a boot-path flag that is not coming, which is the semantic change this bump records. Two factual corrections ride along, both measured rather than inherited: the door `createHonoServerPlugin({ restConfig })` these gaps named DOES NOT EXIST (HonoServerPlugin is a class whose restConfig reader takes only api.basePath for the SPA fallback and never constructs a REST server), and `os serve` does read a config file — the stack's own top-level `api:` block — forwarding exactly two keys out of it (`packages/cli/src/commands/serve.ts#apiConfig`), so 'nothing the CLI reads' was an over-claim. The acceptance clauses, steps, variants and oracles are untouched: what moved is what a runner should conclude from a non-default clause being unreachable, not what it must measure.", "ref": "#15543" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "the masking step named showcase_account, but no stock persona has field-level security there (#23878 checklist-accuracy finding), so the diff was empty for everyone and the clause could only pass vacuously or block. Re-measured on main c91af8ed6: the served /meta/object/showcase_project for a client_liaison member lacks exactly budget, budget_remaining and spent against the admin's copy, while showcase_account is identical for the liaison and a contributor. The fixture line, step 3 and clause 4's verify now name showcase_project and the client_liaison persona", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/approvals.json b/docs/qa/platform-checklist/areas/approvals.json index ab5b38485bb..2cf0c8c52f5 100644 --- a/docs/qa/platform-checklist/areas/approvals.json +++ b/docs/qa/platform-checklist/areas/approvals.json @@ -9,7 +9,7 @@ "title": "Per-group sign-off (会签) needs one approval from EACH group", "since": "v16", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "browser", "personas": ["approver holding exactly one group (e.g. manager)", "second approver holding the other group (e.g. finance/auditor)"], @@ -66,9 +66,9 @@ "evidence": "actions read" }, { - "clause": "the server derives the decision actor from the session — the posted actorId is a hint, not an authority (#3800)", + "clause": "the server records the decision against the authenticated session — a posted actorId naming anyone ELSE is refused, never honored and never silently overridden (#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", + "verify": "capture the approve POST: with an actorId naming another user (e.g. the finance-group approver) it answers 403 FORBIDDEN 'cannot act as …' and writes no action row; with the caller's own id (or none) it succeeds and the recorded action row's actor is the authenticated session user", "evidence": "decision POST + action row" } ], @@ -89,7 +89,8 @@ { "revision": 3, "date": "2026-08-10", "change": "entry path refreshed: the inbox is opened through the account app's Approvals nav entry (component route) rather than the bare /system/approvals deep link, so the item exercises the path a real user takes after #7213", "ref": "#7331" }, { "revision": 4, "date": "2026-08-11", "change": "knownGaps records the better-auth issuer=local:credential provisioning detail — the one field that decides whether Ada can be driven as a real second session or the run falls back to the admin override; it used to be rediscovered every sweep, behind a 'User not found' warn that points at the wrong cause", "ref": "#7530" }, { "revision": 5, "date": "2026-08-18", "change": "the fixture blocker is CLOSED, not merely documented: the showcase now provisions Ada's better-auth credential account at boot (issuer derived from the dev admin's own row), so she signs in on stock seeds. knownGaps rewritten as closed-by-fixture rather than deleted — the issuer detail is the reason the gap existed and the next sweep still needs it. The admin-override fallback is withdrawn as an acceptable path for this item", "ref": "#9308" }, - { "revision": 6, "date": "2026-09-19", "change": "the issuer knownGap is re-anchored rather than re-pointed: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so the cited plugin-auth backfill module no longer exists and has no successor. The reason the gap carries is kept verbatim in substance — a password hash alone was not enough, and the failure was a silent lockout whose 'User not found' warn pointed at the wrong row — and the durable anchor is now the retirement's own migration entry in packages/spec. The clause describing the seed as reading the issuer off the dev admin's credential row is withdrawn as false: there is no issuer to read", "ref": "#17453" } + { "revision": 6, "date": "2026-09-19", "change": "the issuer knownGap is re-anchored rather than re-pointed: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so the cited plugin-auth backfill module no longer exists and has no successor. The reason the gap carries is kept verbatim in substance — a password hash alone was not enough, and the failure was a silent lockout whose 'User not found' warn pointed at the wrong row — and the durable anchor is now the retirement's own migration entry in packages/spec. The clause describing the seed as reading the issuer off the dev admin's credential row is withdrawn as false: there is no issuer to read", "ref": "#17453" }, + { "revision": 7, "date": "2026-10-16", "change": "the actorId clause described a silent override ('a hint … regardless of any actorId in the body'), but the server REFUSES a mismatched actorId (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 on EXP-2001: the admin's approve carrying Ada's id answers 403 FORBIDDEN \"cannot act as 'usr_showcase_auditor_demo' — an approval action is recorded against the authenticated caller\" and adds no action row; the same call carrying the admin's own id answers 200 and the approve row's actor is the admin. The clause now states the stricter, measured behavior; it still fails if a foreign actorId is recorded", "ref": "#24036" } ] }, { @@ -404,7 +405,7 @@ "title": "Every approval action executes its REST route and produces the expected state transition plus a timeline entry", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": ["dev admin (pending approver on the seeded requests; submitter of the invoice request)"], @@ -428,7 +429,7 @@ "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", + "request-info + comment: POST /:id/request-info {comment} AS A PENDING APPROVER of the request (request-info is approver-only: once the admin has reassigned their own slot away in the reassign step, the admin's request-info answers 403 FORBIDDEN \"actor '…' is not a pending approver\" — drive it as the pending approver, e.g. the reassign target with their own token), and POST /:id/comment {comment}, which a non-approver admin may still post", "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" @@ -519,7 +520,8 @@ { "revision": 1, "date": "2026-08-07", "change": "initial — decision-action matrix derived from the approvals REST route ledger and the ADR-0044 revise/resubmit flow shape", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "corrected the resubmit transition the item asserted: it does NOT move the original returned→pending — the approval node's re-entry mints a round-N+1 request while the original stays terminally returned (source-confirmed as by design). Split the old combined revise/resubmit clause into three (revise parks · resubmit mints · original stays returned) and sharpened the maxRevisions verify to name which request the auto-reject lands on. Fixes the sole reason #7517 scored this item PARTIAL", "ref": "#7530" }, { "revision": 3, "date": "2026-08-30", "change": "added the READ_BACK_FAILED clause (#12769, landed #13181 2026-08-29): all ten fresh! non-null assertions on the post-write echo are gone — deciding an org-less request (the schedule/api-trigger shape, #10131) as an org-scoped caller now answers a loud named refusal with the write recorded, never a malformed 200 with request:null. Fixture reality recorded in knownGaps: no seeded flow opens an org-less request on demand, so the live-HTTP leg is blocked(fixture) while the unit pin covers the mechanism", "ref": "#13181" }, - { "revision": 4, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21784 parked on #22017 (row 19). A10 was blocked(fixture): no org-less request can be provisioned, and even an api-hook approval is org-stamped. Disposition: pin. approval-service.test.ts (the #12769 describe) proves the clause's verdict at the service. `automated` names it for that clause, and the knownGap records the refuted api-trigger candidate, the one untried schedule candidate, and the wire-envelope half that stays unobserved on stock", "ref": "#22017" } + { "revision": 4, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21784 parked on #22017 (row 19). A10 was blocked(fixture): no org-less request can be provisioned, and even an api-hook approval is org-stamped. Disposition: pin. approval-service.test.ts (the #12769 describe) proves the clause's verdict at the service. `automated` names it for that clause, and the knownGap records the refuted api-trigger candidate, the one untried schedule candidate, and the wire-envelope half that stays unobserved on stock", "ref": "#22017" }, + { "revision": 5, "date": "2026-10-16", "change": "step 9 ran request-info + comment as the admin on a pending request, but request-info is approver-only (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: request-info by the admin while a pending approver → 200; after the admin reassigns their slot to Ada → the admin's request-info answers 403 FORBIDDEN \"actor … is not a pending approver\", the admin's comment still answers 200, and Ada's request-info answers 200. The step now names the pending approver as the request-info caller; the clauses are unchanged", "ref": "#24036" } ] }, { @@ -665,27 +667,27 @@ "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": 5, + "revision": 6, "priority": "P2", "surface": "mixed", "personas": ["dev admin — the DELEGATOR A: the delegation row is written BY them FOR them (sys_approval_delegation is self-service-only, so the delegator must be whoever is authenticated)", "a SECOND REAL runtime account — the DELEGATE B (Ada Auditor usr_showcase_auditor_demo, provisioned as a real login per knownGaps), who holds their own bearer token and decides the rerouted request as themselves"], "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 flow in a WRITABLE package with a RECORD-TRIGGERED start — start config {objectName: , triggerType: 'record-after-create'} — and 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, whose start is record-triggered the same way). ⚠ Not a trigger-only start: an approval node needs a record, and POST /api/v1/automation//trigger cannot supply one — buildAutomationContext (packages/runtime/src/domains/automation.ts) maps the body's recordId into params only, while the approval node reads its record id from $record, which the engine seeds from context.record, so the run answers 400 \"Approval node '…': no record id in $record\" (packages/plugins/plugin-approvals/src/approval-node.ts)", + "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 register a scratch flow with a RECORD-TRIGGERED start through POST /api/v1/automation on the stock boot — start config {objectName: 'showcase_field_zoo', triggerType: 'record-after-create'} (a stock object; no writable package or scratch object is needed) — and 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, whose start is record-triggered the same way). ⚠ Not a trigger-only start: an approval node needs a record, and POST /api/v1/automation//trigger cannot supply one — buildAutomationContext (packages/runtime/src/domains/automation.ts) maps the body's recordId into params only, while the approval node reads its record id from $record, which the engine seeds from context.record, so the run answers 400 \"Approval node '…': no record id in $record\" (packages/plugins/plugin-approvals/src/approval-node.ts)", "delegator A = the SIGNED-IN dev admin (so the delegation is a self-service write); delegate B = a second real runtime account with its own credentials and bearer token — Ada Auditor (usr_showcase_auditor_demo / auditor.demo@example.com), provisioned as a real login per the knownGap below" ], "knownGaps": [ "⛔ the delegation CANNOT be set up as a third party on behalf of another delegator: sys_approval_delegation is self-service-only and there is no admin exemption (#4839 — bindDelegationWriteGuard rejects a foreign delegator_id with FORBIDDEN, and an absent one is stamped to the caller). Combined with the seeded demo personas having no auth account of their own, the 'admin delegates on behalf of Mei' shape this item used to prescribe is not constructible by ANYBODY. The run drives the mirror image instead — the admin delegates their OWN slot to a real second account — which is also the stronger test, because the delegate decides under a genuinely separate identity and the audit's no-laundering clause becomes falsifiable (#7517)", "[CLOSED by #9308 fixture 1 — the gap text stays because it carries the reason] provisioning a seeded demo persona as a real login needs more than a password hash, and for one release the missing half was an issuer: better-auth 1.7 keyed account identity on (issuer, accountId), so a credential row whose issuer was not the local credential issuer was invisible to findAccountByKey — sign-in failed INVALID_EMAIL_OR_PASSWORD behind a misleading 'User not found' warn pointing at the sys_user row rather than at the account: the user exists and the hash is right, so the warn sends you looking in exactly the wrong place. ⚠️ THE TRAP IS RETIRED, not merely closed by the fixture: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, together with the plugin-auth symbols that existed only to serve it — the durable record is packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts, and there is no successor module this citation could be re-pointed at. The key is (provider_id, account_id), internalAdapter.createAccount supplies both, and the derivation, its failure branch and the whole class of silent lockout behind it are gone. Delegate B is constructible with no setup and nothing about her issuer to get right: seed-approval-demo.ts provisions Ada Auditor's credential account at boot, password DEMO_PERSONA_PASSWORD ('showcase123', demo-personas.ts), pinned by packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts. Drive the real second account; do not record blocked(fixture) for this reason", - "authoring the scratch flow needs a writable/scratch package — the showcase ships read-only" + "[CLOSED — the gap text stays because it carries the reason] authoring the scratch flow used to need a writable/scratch package because the showcase ships read-only; POST /api/v1/automation registers a runtime flow on the stock boot (measured on main c91af8ed6, record-triggered on showcase_field_zoo), so the package is not needed. A runtime-registered flow is removed with DELETE /api/v1/automation/" ] }, "steps": [ "boot showcase isolated (dogfood §0); provision B as a real login — a stock boot already does it, see knownGaps — and hold B's bearer token separately from the admin's", "sign in as the dev admin (the delegator A)", - "in a writable/scratch package author + register (POST /api/v1/automation) a flow whose start is record-triggered on a scratch object (start config objectName + triggerType 'record-after-create') and whose single approval node has approvers=[{type:'user', value:''}], behavior first_response", - "baseline (no delegation): start a run by creating one record of the trigger object (POST /api/v1/data/); 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", + "register (POST /api/v1/automation) a flow whose start is record-triggered on a stock object (start config objectName 'showcase_field_zoo' + triggerType 'record-after-create') and whose single approval node has approvers=[{type:'user', value:''}], behavior first_response", + "baseline (no delegation): start a run by creating one record of the trigger object (POST /api/v1/data/showcase_field_zoo {name, f_master_detail: }); 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 AS A (self-service): POST /api/v1/data/sys_approval_delegation {delegate_id:'', valid_from:, valid_until:, reason:'Annual leave'} — omit delegator_id and the guard stamps the caller; passing a delegator_id that is not the caller is refused (see the negative below)", "start a run AGAIN (create another record of the trigger object); 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 B's inbox/notifications — a topic approval.ooo_substituted notification addressed to B, actionUrl /system/approvals?request= (#1322 M4)", @@ -744,7 +746,8 @@ { "revision": 2, "date": "2026-08-11", "change": "persona shape rewritten around what is constructible: the old fixture had the admin write a delegation on behalf of a non-admin delegator, which sys_approval_delegation's self-service-only guard refuses from everybody (#4839) and which the named delegator could not do either (no auth account). Now A = the signed-in admin delegating their OWN slot and B = a second REAL runtime account deciding with its own bearer token — the mirror image #7517 actually drove, and the stronger test. Added the foreign-delegator refusal as a negative and the better-auth issuer provisioning detail to knownGaps", "ref": "#7530" }, { "revision": 3, "date": "2026-08-18", "change": "delegate B needs no hand-provisioning any more: the showcase gives Ada Auditor a real better-auth credential account on a stock boot, so the issuer knownGap is rewritten as closed-by-fixture. The OTHER two gaps are untouched and still bind — the self-service-only delegation guard (#4839) and the writable/scratch package the individually-routed flow needs", "ref": "#9308" }, { "revision": 4, "date": "2026-09-19", "change": "same re-anchoring as the sibling items: the retired plugin-auth backfill citation is replaced by the retirement's migration entry in packages/spec and the reason is kept, the parenthetical 'issuer derived from the dev admin's own row' is withdrawn as false, and the step that told a runner to check issuer=local:credential when provisioning delegate B goes with it — that field no longer exists", "ref": "#17453" }, - { "revision": 5, "date": "2026-10-13", "change": "fixtures.requires[0] and steps[2], [3], [5] and [8] start the scratch flow from a record instead of POST …/trigger (assertion defect, #23166 A0; parked on #23121, carried by #23169). The trigger route cannot open an approval: buildAutomationContext maps the body's record into params only, the approval node reads its record id from $record, which the engine seeds from context.record, and the run answers 400 'no record id in $record'. QA run #23166 drove the item through a record-triggered start (the approver-bindings.flow.ts shape) and passed every clause. Read from source at 6d7f4df3c: runtime domains/automation.ts buildAutomationContext, plugin-approvals approval-node.ts", "ref": "#23169" } + { "revision": 5, "date": "2026-10-13", "change": "fixtures.requires[0] and steps[2], [3], [5] and [8] start the scratch flow from a record instead of POST …/trigger (assertion defect, #23166 A0; parked on #23121, carried by #23169). The trigger route cannot open an approval: buildAutomationContext maps the body's record into params only, the approval node reads its record id from $record, which the engine seeds from context.record, and the run answers 400 'no record id in $record'. QA run #23166 drove the item through a record-triggered start (the approver-bindings.flow.ts shape) and passed every clause. Read from source at 6d7f4df3c: runtime domains/automation.ts buildAutomationContext, plugin-approvals approval-node.ts", "ref": "#23169" }, + { "revision": 6, "date": "2026-10-16", "change": "the knownGap 'authoring the scratch flow needs a writable/scratch package' is stale (#23878 checklist-accuracy finding): POST /api/v1/automation registers a runtime flow on the stock boot. The fixture line and step 3 now register the user-routed flow that way, record-triggered on the stock showcase_field_zoo, and the run steps create a showcase_field_zoo row to start it. Full re-run on main c91af8ed6 with B = Ada Auditor: baseline pending [A]; an active self-service A→B delegation reroutes the next request to [B] with one ooo_substitute row (actor null, 'A → B — Annual leave'); B's /notifications row is type approval.ooo_substituted with actionUrl /system/approvals?request=ID; B approves with her own token (actor B, via_override false); with valid_until in the past the next request routes back to [A] with no substitution", "ref": "#24036" } ] }, { @@ -752,7 +755,7 @@ "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, + "revision": 2, "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)"], @@ -766,13 +769,13 @@ "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'])", + "open that project's record page — /_console/apps/showcase_app/showcase_project/record/:id, the served record route (the shorthand /apps/showcase_app/showcase_project/:id still lands there: the console's ShorthandRecordRedirect rewrites it); 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)" + "both-sides gate: open the same project's record page as a member who can READ the project but is NOT a pending approver and holds no override — e.g. a contributor-bound member — the header offers NO Approve/Reject (approvals.canDecide false). ⛔ Not the dev admin: on a record whose approval routes away from them the admin still sees 'Override Approve' / 'Override Reject' (the organization-administrator override path), so the admin cannot prove the gate's closed side" ], "acceptance": [ { @@ -800,9 +803,9 @@ "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", + "clause": "the decision buttons are gated by the viewer's approver relationship — a viewer who is neither a pending approver nor an override holder is offered neither Approve nor Reject; an organization administrator off the slate is offered the OVERRIDE pair instead, which is a different control, not this gate failing", "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)", + "verify": "after confirming render via screenshot, the header DOM of the non-approver, non-override member (e.g. a contributor-bound member) has no approve_request/reject_request actions when approvals.canDecide is false (the current user is not in pending_approvers); record, separately, that the admin routed away sees 'Override Approve' / 'Override Reject'", "evidence": "screenshot + header action DOM list" } ], @@ -819,7 +822,8 @@ "examples/app-showcase/src/automation/flows/index.ts#BudgetApprovalFlow (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" } + { "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" }, + { "revision": 2, "date": "2026-10-17", "change": "the both-sides clause and step 9 named no persona, and the natural choice — the dev admin on a record routed away from them — sees 'Override Approve' / 'Override Reject' (#23878 checklist-accuracy finding), so it can never show the closed side; the step and clause 5 now name a non-override member. The record route in step 3 is the served /_console/apps/showcase_app/showcase_project/record/:id (the shorthand form redirects to it). Re-run on main c91af8ed6 with the console at objectui f8267df93aba: on a project whose budget approval is pending for the admin, the admin's header shows Approve and Reject; a contributor-bound member's header shows neither; with the admin's slot reassigned away the admin's header shows Override Approve and Override Reject", "ref": "#24036" } ] }, { @@ -1056,7 +1060,7 @@ "title": "The bell badge and Home's action card agree on the pending-approval count and lead to an app the user can open", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "browser", "personas": ["a non-admin pending approver", "an admin with >9 combined unread notifications + pending approvals"], @@ -1067,11 +1071,11 @@ "enough seeded sys_inbox_message rows plus pending approvals that the combined total exceeds 9, so the CLAMP case is the one exercised (a total of 9 or less never proves the breakdown is unclamped)" ], "knownGaps": [ - "sys_inbox_message is granted by no platform permission set, so a plain member's notification count is structurally 0 and the >9 case can only be reached as an admin — tracked at #7344" + "[STALE premise corrected — #7344 closed 2026-08-11] a plain member's notification count is NOT structurally 0: a member holding only the baseline sets reads its own notifications (GET /api/v1/notifications?view=mine 200), so the >9 case is reachable for a non-admin. Measured on main c91af8ed6: seed it by routing approvals to the member through a runtime user-routed flow (POST /api/v1/automation, record-triggered on showcase_field_zoo; DELETE it afterwards). Notifications of one type COALESCE in the popover ('Approval requested ×9' counts as one), so drive the total past 9 with pending approvals: at 10 pending + 2 notification groups the button read 9+ and the header 12 total" ] }, "steps": [ - "boot showcase isolated; seed >9 combined notifications + pending approvals for the persona", + "boot showcase isolated; seed >9 combined notifications + pending approvals for a NON-ADMIN member (knownGaps: a runtime flow routing {type:'user', value:} on showcase_field_zoo, one create per approval — coalesced notifications count once, so the pending approvals carry the total); sign in as that member through the form", "open the bell popover; screenshot the header line and read the button badge", "open /_console/home; screenshot the 'Needs your attention' card", "click the card's pending-approvals row and record the URL it navigates to", @@ -1093,7 +1097,7 @@ { "clause": "Home's pending-approvals card navigates within an app the user can open — never /apps/setup/...", "oracle": "dom", - "verify": "after a screenshot confirms the card rendered, click it and read the URL: /apps//system/approvals. For a non-admin whose app list excludes setup, a setup URL is a FAIL", + "verify": "after a screenshot confirms the card rendered, click it and read the URL: /apps//system/approvals. For a non-admin whose app list excludes setup, a setup URL is a FAIL. The URL's app segment may be the app's package id (measured: /apps/com.example.showcase/system/approvals, which the console resolves to the Showcase app) or its name; either is an app the session can open", "evidence": "the landed URL + the session's meta/app list" } ], @@ -1108,7 +1112,8 @@ "objectui packages/app-shell/src/console/home/HomePage.tsx (onOpenApprovals → /apps//system/approvals)" ], "history": [ - { "revision": 1, "date": "2026-08-10", "change": "initial — the bell breakdown (objectui#4073) and Home's card target had no checklist coverage", "ref": "#7331" } + { "revision": 1, "date": "2026-08-10", "change": "initial — the bell breakdown (objectui#4073) and Home's card target had no checklist coverage", "ref": "#7331" }, + { "revision": 2, "date": "2026-10-17", "change": "knownGaps[0] said a plain member's notification count is structurally 0, so the >9 case was admin-only (#7344); #7344 closed on 2026-08-11 and the #23878 run saw a member read 2 notifications. Re-run on main c91af8ed6 with the console at objectui f8267df93aba as a baseline-only member seeded with 10 pending approvals and 2 notification groups: the bell button read 9+, the popover header '12 total · 2 notifications + 10 pending approvals', Home's Awaiting approval card 10, the inbox's My Pending tab 10 and the approvals API 10; the card's View all opened /_console/apps/com.example.showcase/system/approvals inside the Showcase app. The knownGap, step 1 and clause 3's verify now say so", "ref": "#24036" } ] }, { @@ -1116,7 +1121,7 @@ "title": "The anonymous /approvals/act door: a minted one-tap token renders a confirm page and one POST decides as exactly the bound approver — every dead-token shape is a designed refusal page with no state change", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": ["dev admin (submitter of the seeded invoice request — remind is submitter-only, and the remind fan-out is what mints the tokens)", "the ANONYMOUS bearer of an action link: a clean HTTP client holding NO session cookie and NO bearer token"], @@ -1138,7 +1143,7 @@ "switch to a CLEAN anonymous client (no cookies, no Authorization header — prove it: GET /api/v1/approvals/requests from the same client answers 401); GET the approve URL — 200 HTML confirm page naming the action, the acting approver id and the request summary, with a POST form; GET it again — the confirm page renders a second time, not the 'Already used' page (peek validates without consuming), then re-read the request and GET /:id/actions as admin: nothing changed (mail-gateway prefetch safety — the GET never mutates). The token row is served through no door, so the second confirm page IS the 'still live' oracle", "POST the form body (token=) anonymously — 200 result page 'Approved · 已通过'; as admin re-read the request (status moved / group tally advanced) and GET /:id/actions — the approve row's actor is the BOUND approver_id, comment 'Via action link'", "replay: POST the same token again — 'Already used · 链接已使用' page; no new action row, no state movement", - "dead-slot leg: on a second pending request mint tokens, decide it through the normal UI/API first, then GET the leftover token URL — 'Already decided · 请求已处理' page; and on a third, reassign the bound approver away first, then GET — 'No longer your approval · 已不在你名下' (the link died with the slot, ADR-0043 invalidation)", + "dead-slot leg: on a second pending request mint tokens, decide it through the normal UI/API first, then GET the leftover token URL — 'Already decided · 请求已处理' page; and on a third, reassign the BOUND approver's own slot away first — POST /:id/reassign {\"from\": \"\", \"to\": \"\"} as the admin, or the bound approver's own reassign with their token — then GET — 'No longer your approval · 已不在你名下' (the link died with the slot, ADR-0043 invalidation). ⚠️ An admin reassign WITHOUT `from` moves the CALLER's own slot (from defaults to the caller's taken slot, approval-service.ts reassign), so when the admin also holds a slot the bound approver's token stays live and the leg scores nothing", "garbage: GET and POST with token=garbage — 'Invalid link · 链接无效' page, 200, no summary rows", "expiry: no live door on this build (see knownGaps) — cite the pinned unit test's 'expired' reason and the RESULT_COPY.expired page copy ('Link expired · 链接已过期'), and record the leg as blocked(fixture)", "scope probe: replay the raw token as a credential everywhere else — Authorization: Bearer on GET /api/v1/approvals/requests and on GET /api/v1/data/showcase_invoice — every such call is unauthenticated (401); the token opens nothing but the act door" @@ -1201,7 +1206,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-30", "change": "initial — the anonymous actionable-link door (ADR-0043) had no checklist coverage despite being an unauthenticated decision path. Authored from source with one register correction: the hunter proposed an emailed-link capture harness as the fixture, but messaging persists the reminder payload (raw action URLs included) on the sys_notification event row, so every token/door clause is stock-runnable; only the email-rendering leg still wants the LogTransport capture (recorded in knownGaps)", "ref": "#sweep-2026-08-30" }, - { "revision": 2, "date": "2026-10-10", "change": "re-pointed after PR #22633 (#22616) closed sys_approval_token to the automatic API (apiEnabled: false, apiMethods: [] — 404 OBJECT_API_DISABLED for every caller): the token-row oracles move to the act door's outcome pages, the request's action history (GET /:id/actions) and the two pinned integration tests (internal-hash, generic-door); the mint clause is scored by test; the expiry leg, whose only recipe was backdating expires_at over the data API, has no surviving door (ttlMs is not REST-reachable and the plugin threads no clock) and is recorded in knownGaps as blocked(fixture) with the pinned 'expired' reason as its oracle. Two steps had contradicted the door before that PR already: the digest has been withheld since #21197, and update was never in the whitelist", "ref": "#22631" } + { "revision": 2, "date": "2026-10-10", "change": "re-pointed after PR #22633 (#22616) closed sys_approval_token to the automatic API (apiEnabled: false, apiMethods: [] — 404 OBJECT_API_DISABLED for every caller): the token-row oracles move to the act door's outcome pages, the request's action history (GET /:id/actions) and the two pinned integration tests (internal-hash, generic-door); the mint clause is scored by test; the expiry leg, whose only recipe was backdating expires_at over the data API, has no surviving door (ttlMs is not REST-reachable and the plugin threads no clock) and is recorded in knownGaps as blocked(fixture) with the pinned 'expired' reason as its oracle. Two steps had contradicted the door before that PR already: the digest has been withheld since #21197, and update was never in the whitelist", "ref": "#22631" }, + { "revision": 3, "date": "2026-10-16", "change": "step 7's dead-slot leg said 'reassign the bound approver away first' without saying who can (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6 on a two-slot request [admin, Ada] minted by remind: the admin's reassign without `from` moved the admin's own slot ([OTHER, Ada]) and Ada's approve token still peeked as the 'Approve · 通过' confirm page; the admin's reassign with from: Ada moved Ada's slot and the same token then rendered 'No longer your approval · 已不在你名下'. The step now passes `from` (or has the bound approver reassign their own slot); the clause is unchanged", "ref": "#24036" } ] }, { @@ -1209,19 +1215,19 @@ "title": "Every approver type resolves the slate it declares — department includes all descendants, deprecated role warns and resolves, and a slate with no concrete person takes the declared onEmptyApprovers fork, never a fake approval", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": ["dev admin (authors the scratch flows; org owner)", "Ada Auditor / Mei Phone (real logins since #9308 — directory rows the graph legs resolve to)"], "fixtures": { "app": "showcase", "requires": [ - "scratch ACTIVE autolaunched flows in a WRITABLE package, one per uncovered approver leg, each with a single approval node whose approvers carry the type under test (shape them after examples/app-showcase/src/automation/flows/approver-bindings.flow.ts, but note the caveat in knownGaps: that file is a DRAFT designer specimen, not a runnable fixture)", + "scratch ACTIVE autolaunched flows, one per uncovered approver leg, each with a single approval node whose approvers carry the type under test — registered at runtime on the STOCK boot through POST /api/v1/automation (the flow-registration door — no writable package is needed), started by a record trigger on a stock object, e.g. start config {objectName: 'showcase_field_zoo', triggerType: 'record-after-create'} and a showcase_field_zoo create carrying name and f_master_detail; deregister each with DELETE /api/v1/automation/ when its leg is done, so two scratch flows never fire on the same create. Shape the approval node after examples/app-showcase/src/automation/flows/approver-bindings.flow.ts, but note the caveat in knownGaps: that file is an OBSOLETE designer specimen, not a runnable fixture", "directory rows for the graph legs: the sys_business_unit tree IS seeded (the real org tree — examples/app-showcase/src/data/seed/index.ts), but unit MEMBERSHIP (sys_business_unit_member) and teams are runtime writes. Memberships go in over the data API as the admin (POST /api/v1/data/sys_business_unit_member). A team does NOT: the data API refuses sys_team and sys_team_member writes (405 OBJECT_API_METHOD_NOT_ALLOWED), so create it with POST /api/v1/auth/organization/create-team {name} and add each member with POST /api/v1/auth/organization/add-team-member {teamId, userId}", "a manager chain for the manager leg: as the admin, POST /api/v1/auth/admin/set-user-manager {userId: , managerId: } — a data-API PATCH of sys_user.manager_id is refused (403, the field is not editable there)" ], "knownGaps": [ - "approver-bindings.flow.ts is status:'draft' with EMPTY values, deliberately — its own docblock says an active flow over those kinds would resolve to nobody and 're-teach the bug' (#3508). It exercises the DESIGNER's Value pickers, so it cannot serve as the runtime specimen the register hypothesized; every leg this matrix drives needs its own scratch active flow (the writable-package gap approvals.ooo-delegation-reroute already records)", + "approver-bindings.flow.ts ships status 'obsolete' with EMPTY values, deliberately (PR #24008, closing #23965) — loaded, never armed: a fresh org has no rows for those kinds, so an armed run would park on the organization's administrators. It exercises the DESIGNER's Value pickers, so it cannot serve as the runtime specimen the register hypothesized; every leg this matrix drives needs its own scratch active flow. (It shipped 'draft' until #23965, and draft flows DO fire their triggers, so every showcase_field_zoo update used to open an admin_rescue request; measured on main c91af8ed6, an update now opens none.) The scratch flows no longer need a writable package: POST /api/v1/automation registers them on the stock boot (fixtures.requires)", "position, org_membership_level and expression already have LIVE stock coverage — the seeded per-group/quorum/budget flows route position, and showcase_dynamic_approval routes org_membership_level + expression (see approvals.dynamic-approver-routing). This matrix owns the missing legs (department+descendants, team, field, manager, user, role, queue, empty-slate forks); do not double-run the covered three — cite the sibling items' runs instead", "sys_team is EMPTY on a stock boot (the seeded 'Team' rows are the demo showcase_team object, not the platform sys_team) — the team leg blocks(fixture) until the run creates a team and its members at runtime, through the organization team doors (fixtures.requires)" ] @@ -1230,7 +1236,7 @@ "variants": ["manager", "position", "department (+ ALL descendant units)", "team", "field (multi-value fan-out)", "expression", "org_membership_level", "role (deprecated → org_membership_level: warn + resolve)", "user", "queue (dead slot, #3508)"], "steps": [ "boot showcase isolated (dogfood §0); sign in as the dev admin; provision the directory rows — BU memberships over the data API, a team and its members through the organization team doors, a manager chain through set-user-manager (fixtures.requires names each door) — and record their ids", - "author + register the scratch flows in a writable package; per leg: trigger the flow, GET /api/v1/approvals/requests?status=pending, GET /:id and read pending_approvers (and pending_approver_names)", + "author + register the scratch flows through POST /api/v1/automation (fixtures.requires: a record trigger on a stock object, no writable package); per leg: trigger the flow by creating one record of its trigger object, GET /api/v1/approvals/requests?status=pending, GET /:id and read pending_approvers (and pending_approver_names); deregister the flow before the next leg", "department leg: point the approver at a PARENT unit whose descendant units hold the members — the slate must union the parent's members with every descendant's, deduped; deactivate a descendant and re-trigger: its members drop out (inactive units are skipped)", "field leg: a multi-select user field naming two users — two slots, in order, never one 'u1,u2' literal", "manager leg: the submitter's manager_id resolves; user leg: the literal id resolves", @@ -1302,6 +1308,12 @@ "date": "2026-10-16", "change": "stale clauses re-pointed (#23950, from QA run #23926): acceptance[4] and acceptance[5] pinned the pre-#22824 shape, where an empty slate under the default admin_rescue opened on the literal type:value slot (position:qa_nobody, queue:support) and waited for the #3424 override. Since the #22824 ruling (PR #22850) that request opens on the request organization's administrators as ordinary approvers, with the abandoned literal and the reason kept in __resolvedFrom, a routed-to-administrators comment on the request and a WARN naming the routing, so both clauses went red against a correct product. Both now score that shape: pending_approvers equals the organization's administrators and never the literal slot, the reason is recorded, the request stays pending; the queue clause keeps its #3508 warn and its never-a-fake-approval property. The queue and empty-slate steps follow, a fallback that resolves to nobody now names where it lands, a negative fails the retired nameless request, and the sources cite organizationAdministrators, emptySlateReason and the admin-rescue-named-slate pin. The fail, auto_approve and fallback legs are unchanged. Measured on the re-run and corrected here, same class (a fixture naming a door the product has closed): the data API refuses sys_team / sys_team_member writes (405) and sys_user.manager_id (403), so fixtures.requires, step 1 and the sys_team knownGap now name the doors that work — POST /api/v1/auth/organization/create-team and add-team-member, and POST /api/v1/auth/admin/set-user-manager", "ref": "#23950" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "residual of the #23878 finding, settled now that #23965 closed: approver-bindings.flow.ts ships status 'obsolete' (PR #24008), not 'draft', so knownGaps[0] and the fixture line stop calling it a draft (re-measured on main c91af8ed6: a showcase_field_zoo update opens no admin_rescue request; three pending requests before and after). The scratch-flow door is re-pointed too (the approvals.ooo-delegation-reroute finding covers this item): POST /api/v1/automation registers a runtime flow on the stock boot, record-triggered on showcase_field_zoo, so no writable package is needed. Re-run of the door with the user leg: a flow routing {type:'user', value:} opened its request with pending_approvers [admin]", + "ref": "#24036" } ] }, @@ -1310,23 +1322,23 @@ "title": "approvalStatusField mirrors every request transition onto the business record — through the record lock's whitelist, attributed to the acting user", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": ["dev admin (authors the scratch flow; decides and recalls)", "a second real login (Ada) as submitter where the recall/pending attribution needs a non-deciding identity"], "fixtures": { "app": "showcase", "requires": [ - "a scratch ACTIVE flow in a WRITABLE package whose approval node declares config.approvalStatusField naming a text field on the trigger object (the spec says the field 'should be readonly on the object' — declare it so, the mirror lands anyway), plus a SECOND node/flow WITHOUT the key for the never-writes side", + "a scratch ACTIVE flow whose approval node declares config.approvalStatusField naming a text field on the trigger object, registered on the STOCK boot through POST /api/v1/automation (no writable package is needed) and record-triggered on a stock object — e.g. start {objectName: 'showcase_field_zoo', triggerType: 'record-after-create'}, approvalStatusField 'f_textarea'. The spec says the field 'should be readonly on the object'; a stock field cannot be redeclared, and the mirror lands either way — author a scratch object in a writable package only if the readonly declaration itself is under test. Plus a SECOND flow WITHOUT the key for the never-writes side; register one flow at a time and DELETE /api/v1/automation/ between legs, so two flows never fire on one create", "for the failure-tolerance clause: a third scratch flow whose approvalStatusField names a NONEXISTENT field" ], "knownGaps": [ - "NO showcase flow declares approvalStatusField (grepped examples/ — zero hits), so nothing here runs on stock seeds: the whole item rides the scratch-flow route, which needs the writable/scratch package gap approvals.ooo-delegation-reroute already records. A one-line addition to a seeded showcase flow (e.g. BudgetApprovalFlow) plus a readonly mirror field on showcase_project would retire this gap", + "NO showcase flow declares approvalStatusField (grepped examples/ — zero hits), so nothing here runs on stock seeds: the whole item rides the scratch-flow route. That route no longer needs a writable package — POST /api/v1/automation registers the flows on the stock boot (fixtures.requires; the writable-package gap approvals.ooo-delegation-reroute recorded is closed). A one-line addition to a seeded showcase flow (e.g. BudgetApprovalFlow) plus a readonly mirror field on showcase_project would still retire this gap", "the 'returned' leg needs the ADR-0044 revise loop on the SAME node that declares the mirror — copy BudgetApprovalFlow's manager step shape (maxRevisions) into the scratch flow, or the send-back transition is not reachable" ] }, "steps": [ - "boot showcase isolated (dogfood §0); author + register the scratch flows; trigger the mirrored flow", + "boot showcase isolated (dogfood §0); register the scratch flows through POST /api/v1/automation (one at a time, fixtures.requires); trigger the mirrored flow by creating a record of its trigger object (e.g. POST /api/v1/data/showcase_field_zoo {name, f_master_detail})", "submit: while the request is pending, GET the trigger record — the mirror field reads 'pending', and the record's updated_by is the SUBMITTER (the pending stamp is attributed to whoever the row calls submitter, #3783)", "decide: approve — the field flips to 'approved' with updated_by = the decider; re-trigger and reject — 'rejected'; re-trigger and recall as the submitter — 'recalled'; re-trigger, send back (revise) — 'returned' (NOTE: the service writes this fifth value though the spec docblock lists only four — assert the actual behavior); drive the revise loop past maxRevisions — the auto-reject stamps 'rejected'", "lock contrast: with lockRecord true (the default) and the request pending, PATCH the record as the submitter — refused (RECORD_LOCKED class); yet the 'pending' mirror already landed on that same locked record — the lock hook whitelists a write whose ONLY change is the configured approvalStatusField", @@ -1381,7 +1393,8 @@ "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts#kernel (the cascade-identity pin: real kernel + real SQL store, positive decider-identity case and the load-bearing user-less negative)" ], "history": [ - { "revision": 1, "date": "2026-08-30", "change": "initial — the opt-in status mirror had no coverage. Grounded in the six service write sites (including 'returned', which the spec docblock omits — recorded so a run asserts actual behavior), the lock hook's mirror-field-only whitelist, and the #3783 acting-user attribution the existing integration test pins; no showcase flow declares the key, so the fixture rides the scratch-flow route (knownGaps)", "ref": "#sweep-2026-08-30" } + { "revision": 1, "date": "2026-08-30", "change": "initial — the opt-in status mirror had no coverage. Grounded in the six service write sites (including 'returned', which the spec docblock omits — recorded so a run asserts actual behavior), the lock hook's mirror-field-only whitelist, and the #3783 acting-user attribution the existing integration test pins; no showcase flow declares the key, so the fixture rides the scratch-flow route (knownGaps)", "ref": "#sweep-2026-08-30" }, + { "revision": 2, "date": "2026-10-16", "change": "the scratch-flow route re-pointed: the flows no longer need a writable package (the approvals.ooo-delegation-reroute finding of #23878 names this item too). POST /api/v1/automation registers them on the stock boot, record-triggered on showcase_field_zoo. Scoped re-run on main c91af8ed6 (approvalStatusField f_textarea): submit stamps 'pending' with updated_by the submitter; the submitter's PATCH while pending answers 409 RECORD_LOCKED while the mirror already sits on the record; approve → 'approved', reject → 'rejected', recall → 'recalled'; a flow without the key leaves the field null at every read; a flow naming f_text (no such field) still finalizes approved and logs WARN '[approvals] mirrorStatusField failed: Unknown field …'. The returned / maxRevisions and cascade legs were not re-run", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/attachments-storage.json b/docs/qa/platform-checklist/areas/attachments-storage.json index 9cdabe7a6bc..a9e56435414 100644 --- a/docs/qa/platform-checklist/areas/attachments-storage.json +++ b/docs/qa/platform-checklist/areas/attachments-storage.json @@ -8,7 +8,7 @@ "qa-scratch-authz": { "title": "Scratch authz parents (qa_vault / qa_shared / qa_nofiles) + two real member personas", "why": "Stock showcase has NO private files-enabled parent: its only `enable.files` object is showcase_project, which is public_read_write AND readable through the showcase `everyone` baseline. Without this recipe the entire authz core of this area — the 403 deny side of download-authz-both-sides, the invisible-parent case of read-inherits-parent-rls, and the read-but-not-edit split of attach-requires-parent-edit — is blocked(fixture) on every run. Run #7635 closed the gap at runtime and scored those items instead of blocking them; this block is that provisioning made permanent and replayable.", - "provenance": "run #7635 (framework 92f26f75) → #7670 Step 6 gained persona B's sys_attachment read after QA run #23166 met the panel's up-front refusal (#23169).", + "provenance": "run #7635 (framework 92f26f75) → #7670 Step 6 gained persona B's sys_attachment read after QA run #23166 met the panel's up-front refusal (#23169). Step 6 pinned the three persona set payloads (#24036, from the intent QA run #23929 recorded; replayed on main c91af8ed6: 200 saves, 201 bindings, the split proof as stated), and step 5 mints three members, invited first.", "app": "showcase", "requires": [ "an admin session that holds the `manage_metadata` capability — PUT /api/v1/meta/:type/:name is capability-gated per ADR-0066 D1 (#6603), so a bare authenticated session is NOT enough (packages/rest/src/rest-route-ledger.ts, the `PUT /api/v1/meta/:type/:name` row)", @@ -46,21 +46,90 @@ }, { "step": 5, - "call": "sign up two fresh members (unique emails, e.g. qa-vault-a@verify.test / qa-vault-b@verify.test) and resolve their sys_user ids with a system-context read", - "expect": "two real principals with their own bearer tokens — NOT admin-impersonation. A guard checked as admin proves nothing (RUNNER.md trap `wrong-persona`).", + "call": "sign up three fresh members (unique emails, e.g. qa-vault-a@verify.test / qa-vault-b@verify.test / qa-vault-c@verify.test) — invite each address first (POST /api/v1/auth/organization/invite-member: the stock invite_only posture answers an uninvited sign-up 403 SELF_REGISTRATION_CLOSED) — and resolve their sys_user ids with a system-context read", + "expect": "three real principals with their own bearer tokens — NOT admin-impersonation. A guard checked as admin proves nothing (RUNNER.md trap `wrong-persona`).", "source": "the house pattern already used by access-security.json (its items provision members as 'fresh sign-ups' whose sys_user ids are resolved system-side)" }, { "step": 6, - "call": "bind each persona by inserting a sys_user_permission_set row in system context", - "expect": "persona A = read-but-NOT-edit on the scratch objects (the attach-requires-parent-edit split); persona B = no grant beyond the everyone baseline (the parent-invisible persona). Prove the split INDEPENDENTLY before scoring any storage clause: read the parent as each persona and confirm the intended visibility, so a later 403 is attributable to the storage gate rather than to a mis-bound persona. ⚠ Persona B needs READ on sys_attachment itself (and nothing on the scratch parents), unless its grants and the everyone baseline already carry it: without it the console's attachments panel refuses up front — 'You don't have access to these attachments.' — before any download is attempted, which is the listing gate answering, not the download gate the deny clauses score (QA run #23166). Check B's sys_attachment read as part of the independent split proof.", + "call": "PUT /api/v1/meta/permission/NAME?package=com.objectstack.qa.attachments for each of the three permission sets in `body` (raw HTTP, the ?package= query included — see knownGaps), then bind each persona with one POST /api/v1/data/sys_user_permission_set {user_id, permission_set} as the admin: A → qa_att_reader, B → qa_att_baseline, C → qa_att_editor", + "body": { + "qa_att_reader": { + "name": "qa_att_reader", + "label": "QA attachments reader", + "objects": { + "qa_vault": { + "allowRead": true + }, + "qa_shared": { + "allowRead": true + }, + "qa_nofiles": { + "allowRead": true + }, + "sys_attachment": { + "allowRead": true, + "allowCreate": true, + "allowEdit": true, + "allowDelete": true + }, + "sys_file": { + "allowRead": true + } + } + }, + "qa_att_baseline": { + "name": "qa_att_baseline", + "label": "QA attachments baseline", + "objects": { + "sys_attachment": { + "allowRead": true, + "allowCreate": true, + "allowDelete": true + }, + "sys_file": { + "allowRead": true + } + } + }, + "qa_att_editor": { + "name": "qa_att_editor", + "label": "QA attachments editor", + "objects": { + "qa_vault": { + "allowRead": true, + "allowCreate": true, + "allowEdit": true + }, + "qa_shared": { + "allowRead": true, + "allowCreate": true, + "allowEdit": true + }, + "qa_nofiles": { + "allowRead": true, + "allowCreate": true, + "allowEdit": true + }, + "sys_attachment": { + "allowRead": true, + "allowCreate": true, + "allowEdit": true + }, + "sys_file": { + "allowRead": true + } + } + } + }, + "expect": "each set save answers 200 (qa_att_reader and qa_att_editor carry an info advisory, security-private-no-readscope on qa_vault — intended: the vault is owner-only), each binding 201. Members hold NO baseline grant on runtime-authored objects (every list answers 403 PERMISSION_DENIED before binding), which is why the sets are pinned here. persona A = read-but-NOT-edit on the scratch objects (the attach-requires-parent-edit split); persona B = no grant on the parents beyond the everyone baseline (the parent-invisible persona) but read/create/delete on sys_attachment; persona C = read/create/edit on the scratch objects (an uploader that can attach to a row it owns). Measured split on main c91af8ed6: A reads qa_shared 200 / edit 403 and an admin-owned qa_vault record 404; B reads neither parent (403 PERMISSION_DENIED) and lists sys_attachment 200; C reads qa_shared 200 and an edit of an admin-owned row 403. Prove the split INDEPENDENTLY before scoring any storage clause: read the parent as each persona and confirm the intended visibility, so a later 403 is attributable to the storage gate rather than to a mis-bound persona. ⚠ Persona B needs READ on sys_attachment itself (and nothing on the scratch parents), unless its grants and the everyone baseline already carry it: without it the console's attachments panel refuses up front — 'You don't have access to these attachments.' — before any download is attempted, which is the listing gate answering, not the download gate the deny clauses score (QA run #23166). Check B's sys_attachment read as part of the independent split proof.", "source": "sys_user_permission_set insert in system context, mirroring showcase-permission-zoo.dogfood.test.ts — the binding shape access-security.json's permission-set items already replay. NOTE (ADR-0090 D5): every authenticated member ALSO holds the everyone baseline additively, so persona B's effective access is the UNION of its grants and that baseline — compute the union before calling a read 'invisible'." } ], "teardown": "DELETE /api/v1/packages/com.objectstack.qa.attachments (client `packages.uninstall`, ledgered at rest-route-ledger.ts) — or simply discard the isolated file DB, which is the cheaper path and the one an isolated boot makes free.", "knownGaps": [ "The SDK helper `meta.saveItem(type, name, item)` does NOT send the `?package=` query — it PUTs the bare path (packages/client/src/index.ts). Steps 2-4 must therefore be issued as raw HTTP with the query string appended, or the scratch objects land outside the package and the teardown above will not take them with it.", - "Field-level detail of the two personas' permission sets (which verbs each set grants) is recorded here as intent, not as a verbatim payload: run #7635 provisioned them through a runtime permission-set/position binding whose exact set body was not captured in the run record. Replay step 6 by the stated OUTCOME (read-not-edit for A, baseline-only for B) and verify it directly, rather than trusting a payload nobody pinned." + "[CLOSED — the three set payloads are pinned in step 6's body, measured on main c91af8ed6 after QA run #23929 recorded their intent] earlier runs (#7635) provisioned the personas through a runtime permission-set/position binding whose exact body was never captured, so the recipe carried intent only. Replay step 6's body literally; if a save answers anything but 200, re-read the citation before assuming the recipe rotted" ] }, "qa-media-constraints": { @@ -173,10 +242,11 @@ "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": 4, + "revision": 5, "priority": "P1", "surface": "mixed", - "personas": ["admin (uploader/owner)", "signed-up member who cannot read the parent record", "anonymous"], + "personas": ["admin (uploader/owner)", "signed-up member who cannot read the parent record (qa-scratch-authz persona B: the API deny clauses)", + "qa-scratch-authz persona A, entitled on qa_shared and then de-granted (the browser clause)", "anonymous"], "fixtures": { "app": "showcase", "requires": [ @@ -199,7 +269,7 @@ "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 — persona B, holding READ on sys_attachment (qa-scratch-authz step 6) — attempt the download, and capture the surfaced copy. Without that read the panel refuses up front ('You don't have access to these attachments.') before any download is attempted: that is the listing gate, not the download denial acceptance[4] scores" + "in the browser, as persona A (reads qa_shared through its qa_att_reader binding, qa-scratch-authz step 6), open a qa_shared record that carries an attachments-scope file (/_console/apps/showcase_app/qa_shared/record/ID), open its Attachments tab and confirm the file is listed; then, server-side and WITHOUT reloading the page, remove A's read (as admin DELETE /api/v1/data/sys_user_permission_set/BINDING_ID, A's qa_att_reader row), click the listed file's Download, and capture the 403 on GET /api/v1/storage/files/FILE_ID/url plus the panel's surfaced copy; re-bind A afterwards (POST the same row back). Persona B cannot stand in for A here: with no read on the parent the record page itself refuses first ('You don't have access to qa_vault records'), so RecordAttachmentsPanel never mounts and there is no download to attempt. A reload after the revoke re-runs the record and listing gates and refuses before any download, which is not the download denial acceptance[4] scores" ], "acceptance": [ { @@ -229,7 +299,7 @@ { "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)", + "verify": "persona A's already-open panel, after A's read is removed, shows the mapped copy on Download (the 403 is on the /url grant request) (RecordAttachmentsPanel.tsx friendlyError reads both the enveloped error.code and the legacy top-level code)", "evidence": "panel screenshot" } ], @@ -259,6 +329,12 @@ "date": "2026-10-13", "change": "steps[6] names persona B's sys_attachment read, and the area recipe qa-scratch-authz step 6 now provisions it (persona recipe gap, #23166 A4; parked on #23121, carried by #23169). Without read on sys_attachment the attachments panel refuses before any download, so acceptance[4]'s friendly-denial screenshot would capture the listing refusal instead of the download one. QA run #23166 granted B that read and passed the clause. No clause changed", "ref": "#23169" + }, + { + "revision": 5, + "date": "2026-10-17", + "change": "steps[6] re-pointed to the reachable shape (checklist-accuracy finding from QA run #23929, carried on #23878): persona B never reaches RecordAttachmentsPanel, because without read on the parent the record page refuses first. The step now opens the panel as persona A while entitled, removes A's qa_att_reader binding server-side without a reload, and clicks Download; acceptance[4]'s verify names that persona and the personas list carries A. The clause still scores the mapped copy against a raw 403. Re-run on main c91af8ed6 with the console at objectui f8267df93aba: A opened /_console/apps/showcase_app/qa_shared/record/ID, the Attachments tab listed sfsp-24036.txt; the admin deleted A's sys_user_permission_set row (200); Download sent GET /api/v1/storage/files/ID/url → 403 and the panel showed 'You don't have access to download this attachment.' above the row, with no status text; A was re-bound (201). As B, /_console/apps/showcase_app/qa_vault/record/ID rendered 'You don't have access to qa_vault records' with no Attachments tab", + "ref": "#24036" } ] }, @@ -360,7 +436,7 @@ "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": 6, + "revision": 7, "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", "admin (isolates clause 5's FILES_DISABLED refusal from ATTACHMENT_PARENT_ACCESS by holding EDIT on both the original and target parent)"], @@ -385,7 +461,7 @@ "as the same member, list that parent's attachments — reading must still work (read inherits parent READ, attach requires parent EDIT: two different gates)", "as a parent editor, POST the same attach payload with a spoofed uploaded_by of another user and read the row back", "as admin, POST one throwaway qa_nofiles record ({ name: 'qa-nofiles-probe' }) for a real parent_id (qa_nofiles is a scratch OBJECT from the recipe with no standing rows), then PATCH the attachment created in the previous step to re-point it — { parent_object: 'qa_nofiles', parent_id: } — as admin (who can edit both the original parent and the qa_nofiles record it just created, isolating the refusal from ATTACHMENT_PARENT_ACCESS) and capture the refusal. This is the UPDATE half of the #10170 gate (enforceFilesCapability registered on beforeUpdate as well as beforeInsert, packages/plugins/plugin-audit/src/audit-writers.ts): re-read the attachment afterward and confirm parent_object/parent_id are UNCHANGED", - "as a member who is neither the uploader nor a parent editor, DELETE the attachment and capture the refusal", + "as a member who is neither the uploader nor a parent editor but CAN READ the parent (persona A on a qa_shared row), DELETE the attachment and capture the refusal; repeat as a member who cannot read the parent at all (persona B) and capture that answer too", "as the uploader, DELETE their own attachment on a parent they cannot edit — the uploader may always detach", "drive a predicate-less multi-delete from an ENGINE HOLDER — ql.delete('sys_attachment', { multi: true, context }) with no id and no where — and capture the refusal. ⛔ Not over REST: DeleteManyDataRequestSchema requires an ids list (#3897), so the HTTP lane cannot express the shape and its schema rejection must never be scored as this refusal" ], @@ -403,9 +479,9 @@ "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", + "clause": "delete is gated on uploader-or-parent-editor: an outsider who can read the parent gets 403 ATTACHMENT_DELETE_DENIED; an outsider who cannot read the parent gets 404 RECORD_NOT_FOUND, because the attachment row itself is invisible to it (attachment read inherits the parent's read); 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", + "verify": "the delete attempts split exactly that way — the parent-reading outsider 403 ATTACHMENT_DELETE_DENIED, the non-reader 404 RECORD_NOT_FOUND, the uploader 2xx — and the attachment row survives both refusals; a multi-delete requires EVERY matched row to pass", "evidence": "both delete responses" }, { @@ -468,6 +544,12 @@ "date": "2026-10-15", "change": "the parent-EDIT authority re-cited to the gate on main, and the source anchor re-pointed. #22513 (ce3d0ad41) removed the sharing shape's two-state `canEdit` member from attachment-access-hooks.ts: the attach rule, the parent-editor limb of update and delete, and an update-verb re-point now ask one composition, mayEditParent, which reads plugin-sharing's tri-state checkEdit and sends an abstention to the master-detail write check, so a controlled_by_parent parent is judged through its master. The source anchor on that file named canEdit and no longer resolved (check:platform-checklist ABSENT SYMBOL, and the area's anchor floor read 27 of 28); it now names mayEditParent, which holds that composition. The fixtures requirement and clause 0 stop naming canEdit as the authority. What each clause asserts is unchanged — the read-but-not-edit persona is still refused 403 ATTACHMENT_PARENT_ACCESS — and the steps, personas and provisioning are unchanged", "ref": "#23593" + }, + { + "revision": 7, + "date": "2026-10-16", + "change": "clause 3's outsider was scored 403 ATTACHMENT_DELETE_DENIED without saying whether it could read the parent (#23879 out_of_scope_findings: a non-reader outsider now gets 404). Re-measured on main c91af8ed6 with the qa-scratch-authz recipe: deleting persona C's attachment on a qa_shared row answers persona A (reads the parent, neither uploader nor editor) 403 ATTACHMENT_DELETE_DENIED and persona B (cannot read the parent) 404 RECORD_NOT_FOUND — the attachment row is invisible to B, because attachment read inherits the parent's. Step 5 and the clause now name both outsiders. Also re-run: A's attach to the readable-not-editable parent is 403 ATTACHMENT_PARENT_ACCESS while A's list answers 200, and C's spoofed uploaded_by is server-stamped to C", + "ref": "#24036" } ] }, @@ -476,7 +558,7 @@ "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": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": ["seeded admin (admin@objectos.ai)"], @@ -494,8 +576,8 @@ "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)", "while tombstoned AND holder-less, GET /api/v1/storage/files//url: 404 FILE_NOT_FOUND", - "attach a NEW join row onto the still-tombstoned file_id and immediately GET the download URL again, WITHOUT waiting for any sweep: 200 (#10246 — the download path asks the reap guard's own holder question, so a file the sweep would refuse to reap is a file the download path serves)", - "re-attach the same file_id to 'Website Relaunch' within the grace window and re-read: status back to 'committed', deleted_at null", + "attach a NEW join row onto the still-tombstoned file_id and re-read sys_file BEFORE any download: the attach hook has already un-tombstoned it (status 'committed', deleted_at null), so the live data door never presents a 'deleted' row with a live holder. Then GET the download URL: 200. The #10246 shape itself — a tombstoned row that a holder still references downloads 200 and the download leaves the tombstone as it was — is reachable only below the hooked door, and is scored from packages/services/service-storage/src/tombstone-download-live-reference.test.ts", + "the step-8 re-attach IS the within-grace-window revival: record its sys_file read (status back to 'committed', deleted_at null) as clause 5's evidence; a second re-attach of the same file_id to 'Website Relaunch' reads the same", "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": [ @@ -514,7 +596,7 @@ { "clause": "the refusal is keyed to NO REMAINING HOLDER, not to the tombstone: a 'deleted' file that still has at least one live sys_attachment join row (or a live ref_* owner) downloads with 200 immediately, with no sweep in between; the tombstone row is NOT rewritten by the download (#10246)", "oracle": "api", - "verify": "GET /url on the tombstoned file 404s while holder-less and 200s once a join row is attached, in the same grace window; a sys_file re-read after the 200 still shows status 'deleted' with deleted_at set", + "verify": "live: GET /url on the tombstoned file answers 404 FILE_NOT_FOUND while holder-less; after a hooked re-attach the sys_file row already reads 'committed' before any download (the attach hook un-tombstones first) and /url answers 200 with no sweep in between. The 'deleted row + live holder ⇒ 200, tombstone not rewritten by the download' half has no live door — every attach through the data API un-tombstones first, and the ref_* limb needs a field-owned release — so it is scored from packages/services/service-storage/src/tombstone-download-live-reference.test.ts ('serves a tombstoned file that still has a live join row — on BOTH download endpoints', 'and the sweep AGREES', '404s again the moment the LAST holder goes', 'serves a tombstone held through the ref_* ownership columns')", "evidence": "the two /url responses plus the post-download sys_file read" }, { @@ -542,7 +624,7 @@ ], "variants": ["pending", "committed", "deleted"], "traps": ["wrong-panel"], - "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts" }, + "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts + packages/services/service-storage/src/tombstone-download-live-reference.test.ts (#10246 — clause 3's tombstone-with-a-live-holder half, which no live door can stage)" }, "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)", @@ -551,7 +633,8 @@ ], "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" }, - { "revision": 2, "date": "2026-08-23", "change": "clause 2 was FALSIFIED by #10246 and is repaired here, not merely re-worded. It stated the download refusal as 'status != committed', which was true when written and is not any more: the download routes now ask the reap guard's own findFileHolder before refusing a tombstone, so a 'deleted' file with a live sys_attachment join row (or a live ref_* owner) serves 200 inside the grace window instead of 404ing for up to 30 days and then silently starting to work once a sweep ran. A runner scoring the old clause would have marked the FIXED behaviour as a failure. The clause is now keyed to 'pending', which is the limb that genuinely still refuses; a new clause pins the widened one AS A PAIR (404 while holder-less, 200 once a holder exists, tombstone row unrewritten) because proving only the 200 proves that something got wider and not that the boundary held. Two steps added for the new clause, and step 2's parenthetical stopped asserting a committed-only rule. Note for the next author: docs-drift could not have caught this — it is symbol-anchor precision-first (#9192) and cannot see a prose claim inside a clause string.", "ref": "#10246" } + { "revision": 2, "date": "2026-08-23", "change": "clause 2 was FALSIFIED by #10246 and is repaired here, not merely re-worded. It stated the download refusal as 'status != committed', which was true when written and is not any more: the download routes now ask the reap guard's own findFileHolder before refusing a tombstone, so a 'deleted' file with a live sys_attachment join row (or a live ref_* owner) serves 200 inside the grace window instead of 404ing for up to 30 days and then silently starting to work once a sweep ran. A runner scoring the old clause would have marked the FIXED behaviour as a failure. The clause is now keyed to 'pending', which is the limb that genuinely still refuses; a new clause pins the widened one AS A PAIR (404 while holder-less, 200 once a holder exists, tombstone row unrewritten) because proving only the 200 proves that something got wider and not that the boundary held. Two steps added for the new clause, and step 2's parenthetical stopped asserting a committed-only rule. Note for the next author: docs-drift could not have caught this — it is symbol-anchor precision-first (#9192) and cannot see a prose claim inside a clause string.", "ref": "#10246" }, + { "revision": 3, "date": "2026-10-16", "change": "steps 8 and 9 assumed a tombstoned file could be held by a fresh join row and downloaded while still 'deleted' (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: after the last join row's delete the row reads deleted with deleted_at set and /url answers 404 FILE_NOT_FOUND; the very next attach through the data API un-tombstones it before any download (committed, deleted_at null), and /url then answers 200. Step 8 now says so, step 9 records the revival from the same re-attach, and clause 3's live verify keeps the 404/200 legs while its 'tombstone not rewritten by the download' half is scored from tombstone-download-live-reference.test.ts, now named in automated.ref", "ref": "#24036" } ] }, { @@ -629,7 +712,7 @@ "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": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": ["seeded admin (admin@objectos.ai)"], @@ -651,7 +734,7 @@ "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", - "drive 'failed' (#7667): open a third session and POST its /complete with a backend completion that throws (test seam / an eTag list the backend rejects) — the route answers 500 and the sys_upload_session row must read 'failed', NOT the 'completing' it stopped at before #7667; the same uploadId re-completed successfully afterwards must overwrite it with 'completed' (failed records an attempt, it does not lock the session)", + "drive 'failed' (#7667) through the TEST SEAM: a backend whose completeChunkedUpload throws on a part list the session accepts — the route answers 500 and the sys_upload_session row reads 'failed', NOT the 'completing' it stopped at before #7667; the same uploadId re-completed successfully afterwards overwrites it with 'completed' (failed records an attempt, it does not lock the session). Pinned by packages/services/service-storage/src/storage-routes.test.ts ('terminal upload-session statuses (#7667)': \"stamps 'failed' on the row when the backend completion throws\", \"lets a retried completion overwrite 'failed' with 'completed'\"). ⛔ A bogus eTag list is NOT a live door to 'failed' any more: the completion door judges the parts against the chunks the session holds BEFORE it touches the backend (#22313), so a mismatched eTag answers 409 RESOURCE_CONFLICT ('… lists chunk 0 with an eTag other than the one this upload holds …'), nothing is assembled and the row stays 'in_progress'. Live, record that refusal instead", "drive 'expired' (#7667): backdate a live session's expires_at (system write) WITHOUT running the sweep, then (a) PUT a chunk → 410 UPLOAD_SESSION_EXPIRED and the row reads 'expired' with uploaded_chunks unchanged, (b) GET /progress → 200 with status 'expired' (progress REPORTS the expiry rather than refusing it — 'expired' is a declared member of UploadProgressSchema.status and the SDK's resumeUpload polls it first)" ], "acceptance": [ @@ -711,7 +794,8 @@ "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: chunked-session lifecycle + multipart-abort guard from the reap-guard source, variants pinned to the sys_upload_session status enum; S3-only consequences honestly recorded as a known gap", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "step-text correction from run #7635: the chunked-init step said `size`, but the route destructures `totalSize` and 400s INVALID_REQUEST without it — a runner following the old text could not open a session at all. Step now names totalSize and the 5 MiB chunkSize floor (which silently rewrites totalChunks when a caller asks for less), with storage-routes.ts added to source as the grounding. Clause 4's failed/expired enforce-or-remove finding from the same run is deliberately NOT touched here — it is owned by #7667", "ref": "#7671" }, - { "revision": 3, "date": "2026-08-11", "change": "closes the clause-5 finding revision 2 deferred: 'failed' and 'expired' were declared, reaped on, and published by UploadProgressSchema with NO producer, so run #7635's scan could only ever return ['in_progress','completed']. #7667 took the ENFORCE branch of ADR-0049 (removal would have forked the object from the spec's progress contract), so both statuses now have named writers — two steps added to drive them, a new acceptance clause for the 410 UPLOAD_SESSION_EXPIRED refusal, and the transient 'completing' recorded as a knownGap instead of an unreachable-variant FAIL", "ref": "#7667" } + { "revision": 3, "date": "2026-08-11", "change": "closes the clause-5 finding revision 2 deferred: 'failed' and 'expired' were declared, reaped on, and published by UploadProgressSchema with NO producer, so run #7635's scan could only ever return ['in_progress','completed']. #7667 took the ENFORCE branch of ADR-0049 (removal would have forked the object from the spec's progress contract), so both statuses now have named writers — two steps added to drive them, a new acceptance clause for the 410 UPLOAD_SESSION_EXPIRED refusal, and the transient 'completing' recorded as a knownGap instead of an unreachable-variant FAIL", "ref": "#7667" }, + { "revision": 4, "date": "2026-10-16", "change": "step 8 offered 'an eTag list the backend rejects' as a live door to the 'failed' status (#23878 checklist-accuracy finding). Re-measured on main c91af8ed6: a chunked upload with its one chunk stored and a completion naming a bogus eTag answers 409 RESOURCE_CONFLICT before the backend is called, and the session reads in_progress afterwards — the #22313 completion judge refuses first. 'failed' is now reachable only through a backend that throws on an accepted part list, i.e. the test seam; the step names the storage-routes.test.ts pins that drive it and records the live 409. The clause listing the enum's producers is unchanged", "ref": "#24036" } ] }, { @@ -802,7 +886,7 @@ "title": "A field's declared accept/maxSize is re-enforced SERVER-SIDE on record write — the control the client widget check explicitly is not — with the declared missing-metadata and dotless-extension holes scored as documented boundaries", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": ["seeded admin (admin@objectos.ai) — the check is a declaration constraint, not an authz gate: it binds every caller identically, so no restricted persona is needed"], @@ -820,7 +904,7 @@ "knownGaps": [ "FIXTURE GAP (why the recipe exists): the showcase declares accept/maxSize only on ACTION PARAMS (examples/app-showcase/src/ui/actions/index.ts,337) — that lane feeds the dialog widget and the ADR-0059 Confirm-while-uploading guard pinned by records-forms.upload-guard-blocks-confirm, and never reaches assertFileConstraints, which fires on OBJECT-FIELD references at record write. No showcase object field carries either key, so on stock seeds this item cannot run at all. Landing one constrained field in the showcase seeds would retire the qa-media-constraints recipe.", "CONTENT-SNIFFING BOUNDARY (record so runs do not score it as a leak): enforcement is DECLARATION-based, never content-based. sys_file.mime_type is taken verbatim from the client's own request body on both upload doors — presigned (storage-routes.ts destructure, persisted, even echoed back as the upload content-type header) and chunked — and nothing on the upload path sniffs magic bytes (no file-type/sniffing code exists in service-storage; measured by search, only fs.readdir withFileTypes matches). A caller may therefore upload arbitrary bytes while declaring image/png and pass every accept test. That is the boundary of what the platform claims; a run demonstrating it records a documented-boundary observation, never a FAIL of this item.", - "WIRE-ENVELOPE (per source, unmeasured — the run must record what it sees): FileConstraintError declares `code: 'ERR_FILE_CONSTRAINT'` (registered, packages/spec/src/api/error-code-ledger.zod.ts) but NO `status`/`statusCode` (file-reference-lifecycle.ts), and rest's classifyDataError has no ERR_FILE_CONSTRAINT branch — so the declared-status passthrough never fires and the refusal should exit the /api/v1/data door through the sanitized 500 INTERNAL_ERROR terminal (packages/rest/src/error-response.ts UNCLASSIFIED_FAULT), the field/accept prose reaching the server LOG rather than the body. Its sibling FileFieldBulkWriteError documents `status: 400` as exactly what prevents that promotion (file-reference-lifecycle.ts), and #7525 fixed the same shape for statusCode-declaring hooks. Consequence for scoring: the enforcement clauses pass on the NO-ROW oracle regardless of envelope; a measured sanitized 500 is a wire-contract finding to EXTRACT as its own card, and a measured 4xx carrying ERR_FILE_CONSTRAINT means the product improved — revise clause 2." + "WIRE-ENVELOPE — MEASURED, the source analysis this row used to carry is superseded: FileConstraintError now declares `status: 400` beside its registered `code: 'ERR_FILE_CONSTRAINT'` (packages/services/service-storage/src/file-reference-lifecycle.ts, the same ADR-0112 shape as FileFieldBulkWriteError), so the REST declared-status passthrough answers 400 ERR_FILE_CONSTRAINT with the bounded message in the body — the message names the field and the constraint ('File type … is not permitted by the accept list declared for 'poster' (image/png, image/jpeg)', 'File exceeds the maximum size declared for 'poster' (2097152 bytes > 1048576 bytes)'), measured on main c91af8ed6. It is no longer a sanitized 500, and since #23932 closed rest no longer logs a declared client refusal as '[REST] Unhandled error' — the info-level server log carries no line for it at all. Consequence for scoring: the enforcement clauses still pass on the NO-ROW oracle, and clause 3 scores the wire answer" ] }, "steps": [ @@ -849,9 +933,9 @@ "evidence": "the refusal body + the empty list" }, { - "clause": "the refusal is attributable to the constraint: the server log carries the FileConstraintError prose naming the FIELD and the accept list (or the declared byte counts), and the run RECORDS the wire status/code it measured. Per source the expected current wire shape is the sanitized 500 INTERNAL_ERROR with the prose withheld from the body (ERR_FILE_CONSTRAINT is ledgered but the error declares no HTTP status and classifyDataError has no branch for it — see knownGaps): that measured 500 does NOT fail this clause; it is extracted as a wire-contract card. A 4xx carrying ERR_FILE_CONSTRAINT and the prose means the envelope improved — revise this clause with the measurement", - "oracle": "log", - "verify": "log excerpt around the refusal shows the constraint message ('not permitted by the accept list declared for' / 'exceeds the maximum size declared for') naming the field; the run record states the measured HTTP status + body code beside it", + "clause": "the refusal is attributable to the constraint on the WIRE: the write answers 400 with code ERR_FILE_CONSTRAINT — the registered code of this one refusal — and its error names the field (the wording is not pinned beyond naming the field: it states the accept list or the declared byte counts). A sanitized 500, or a 400 carrying any other code, is the fail", + "oracle": "api", + "verify": "the accept probe and the size probe each answer status 400 with body code ERR_FILE_CONSTRAINT and an error that names 'poster'; record the message beside the verdict (it is no longer in the server log — see knownGaps)", "evidence": "log excerpt + the measured wire status/code" }, { @@ -896,7 +980,8 @@ "records-forms.upload-guard-blocks-confirm owns the CLIENT half (ADR-0059 dialog guard over ACTION-PARAM accept/maxSize, examples/app-showcase/src/ui/actions/index.ts,337). Cross-reference, do not duplicate — that item never touches this record-write seam" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "new item from the scoped scan-functionality sweep (扫描功能): the server-side re-enforcement of field accept/maxSize had NO coverage on either lane — the only pinned guard is the client dialog's (records-forms.upload-guard-blocks-confirm), which the enforcement source itself calls 'a convenience rather than a control'. Authored with the qa-media-constraints recipe (no stock showcase field declares either key), the two source-documented holes (missing metadata, dotless-name vs extension-only accept) as documented-boundary passes, the declaration-based content boundary recorded so runs do not score sniffing absence as a leak, and the per-source wire-envelope analysis (ERR_FILE_CONSTRAINT ledgered but status-less → sanitized 500 expected) carried as a knownGap for the run to measure rather than rediscover", "ref": "claude/new-session-0pv25p" } + { "revision": 1, "date": "2026-08-20", "change": "new item from the scoped scan-functionality sweep (扫描功能): the server-side re-enforcement of field accept/maxSize had NO coverage on either lane — the only pinned guard is the client dialog's (records-forms.upload-guard-blocks-confirm), which the enforcement source itself calls 'a convenience rather than a control'. Authored with the qa-media-constraints recipe (no stock showcase field declares either key), the two source-documented holes (missing metadata, dotless-name vs extension-only accept) as documented-boundary passes, the declaration-based content boundary recorded so runs do not score sniffing absence as a leak, and the per-source wire-envelope analysis (ERR_FILE_CONSTRAINT ledgered but status-less → sanitized 500 expected) carried as a knownGap for the run to measure rather than rediscover", "ref": "claude/new-session-0pv25p" }, + { "revision": 2, "date": "2026-10-16", "change": "clause 3 and knownGaps WIRE-ENVELOPE expected, per an unmeasured source reading, the sanitized 500 INTERNAL_ERROR with the prose only in the server log, and told the next run to revise the clause if a 4xx appeared (#23878 checklist-accuracy finding). It did: re-measured on main c91af8ed6 with the qa-media-constraints recipe, the accept probe and the size probe both answer 400 ERR_FILE_CONSTRAINT with the field-naming message in the body (FileConstraintError now declares status 400), the compliant control answers 201, and the info-level log carries no line for the refusal (#23932 closed). The clause's oracle moves from log to api and scores status, code and the named field; the knownGap records the measured envelope", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 0ca1f172ccc..ab9ee960c13 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -46,6 +46,7 @@ "body": { "name": "qa_always_fails", "label": "QA — always fails terminally", + "errorMessage": "QA always-fails probe: the unroutable http node failed", "type": "autolaunched", "status": "active", "nodes": [ @@ -54,7 +55,7 @@ ], "edges": [{ "id": "e1", "source": "start", "target": "boom" }] }, - "expect": "2xx on the author. On trigger the run DISPATCHES, the http node fails against the unroutable host, and — with no try_catch region and no errorHandling.strategy — the run ends `status: 'failed'`, which is the 400 FLOW_FAILED row. ⚠️ Do NOT wrap it in try_catch: a handled failure is a SUCCESSFUL run and scores the wrong row. The unroutable-host technique (api.example.com) is the one automation.flow-error-handling already relies on for a deterministic failure.", + "expect": "2xx on the author. On trigger the run DISPATCHES, the http node fails against the unroutable host, and — with no try_catch region and no errorHandling.strategy — the run ends `status: 'failed'`, which is the 400 FLOW_FAILED row. ⚠️ Do NOT wrap it in try_catch: a handled failure is a SUCCESSFUL run and scores the wrong row. The unroutable-host technique (api.example.com) is the one automation.flow-error-handling already relies on for a deterministic failure. The body declares errorMessage (the flow author's own failure text) so the FLOW_FAILED refusal carries error.details.errorMessage beside details.summary — automation.trigger-status-contract clause 4 reads it; without it the trigger answers details {summary} only (measured on main c91af8ed6)", "source": "the 400 row keys on the engine's lifecycle verdict `status: 'failed'`, never on message text (packages/runtime/src/flow-dispatch-status.ts#classifyFlowRefusal, classifyFlowRefusal); examples/app-showcase/src/automation/flows/index.ts#showcase_resilient_sync (showcase_resilient_sync) is the precedent for the unroutable host" }, { @@ -190,7 +191,7 @@ "title": "Time-relative flow triggers author first-class and fire per matching record", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": [ @@ -199,14 +200,15 @@ "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)" + "showcase_task_due_reminder (type schedule, start config.timeRelative {object: showcase_task, dateField: due_date, offsetDays: [3,1], filter: {status: {$ne: 'done'}}}, runAs system)", + "⚠️ this item needs package-authored scheduled work switched ON at boot: export OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 (with OS_PORT). On a default boot the flow is unbound by deployment policy — the banner says \"1 flow declares a 'time_relative' trigger but is NOT bound — disabled by deployment policy\", and GET /api/v1/automation/_status reports bound:false with that reason — so no sweep ever comes, which is the policy working, not a trigger defect" ], "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", + "boot showcase isolated (dogfood §0) with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 exported (fixtures.requires); 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)", @@ -274,6 +276,12 @@ "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-10-16", + "change": "the item assumed the time_relative trigger binds on a stock boot (#23878 automation slice finding 1; #23879 out_of_scope_findings). Re-measured on main c91af8ed6: unset OS_AUTOMATION_SCHEDULED_WORK_ENABLED leaves showcase_task_due_reminder unbound by deployment policy (banner: '1 flow declares a 'time_relative' trigger but is NOT bound — disabled by deployment policy'; _status bound:false), and with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 _status reports it bound, triggerType time_relative. The fixture line and step 1 now name the switch; the sweep itself was not re-run", + "ref": "#24036" } ] }, @@ -380,7 +388,7 @@ "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": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": [ @@ -393,7 +401,7 @@ ], "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", - "showcase_fan_out_notify (the parallel + http owner in the map above) posts to Slack and needs outbound egress. On an egress-denied runner, prove parallel on another run that carries it (showcase_nested_fan_out_reminders — a loop-body parallel), and record http as blocked(environment) rather than FAIL when its only located steps failed for want of egress (#21784 FE13)" + "showcase_fan_out_notify (the parallel + http owner in the map above) posts to Slack and needs outbound egress. On an egress-denied runner, prove parallel on another run that carries it (showcase_nested_fan_out_reminders — a loop-body parallel), and record http as blocked(environment) rather than FAIL when its only located steps failed for want of egress (#21784 FE13). http itself is provable without egress on a SELF-PINGING scratch flow: register (POST /api/v1/automation) start → http {method: 'GET', url: 'http://127.0.0.1:/api/v1/health'} → end, trigger it (POST /api/v1/automation//trigger), read the run (http step success), then DELETE /api/v1/automation/ — measured on main c91af8ed6: 200, run completed, http success" ] }, "variants": [ @@ -422,7 +430,7 @@ "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 WITH `assignee` set to a real user's email, under a project that has an `owner`, 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). ⚠ The notify steps address the task's assignee and its project's owner: a task created without an assignee, or under a project without an owner, makes them fail LOUDLY for want of a recipient — a correct refusal, not a notify FAIL (QA run #23166)", + "record_change chain: POST a showcase_task WITH `assignee` set to a real user's email, under a project that has an `owner` (the project lookup is required), with status 'in_review' — task_status_flow allows done only from in_review and does not check a create — 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). ⚠ The notify steps address the task's assignee and its project's owner: a task created without an assignee, or under a project without an owner, makes them fail LOUDLY for want of a recipient — a correct refusal, not a notify FAIL (QA run #23166)", "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", @@ -523,6 +531,12 @@ "date": "2026-10-13", "change": "steps[2] sets an assignee on the run-created task (recipe note, #23166 A1; parked on #23121, carried by #23169). The chain's notify steps address the task's assignee and its project's owner (showcase_task_done_notify_owner hops record.project.owner), so a task created without them makes notify refuse loudly for want of a recipient, which is correct. QA run #23166 met that refusal on tasks and projects created without an assignee or owner; its notify cell still passed (only A1-http was blocked, for egress). No clause changed", "ref": "#23169" + }, + { + "revision": 6, + "date": "2026-10-16", + "change": "the record_change chain's 'POST a task, then PATCH done' failed as written (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 3 now creates the task with status 'in_review'. The http knownGap names the self-pinging scratch-flow recipe the #23878 automation slice located success on (finding 8): a runtime flow GETting the server's own /api/v1/health completed with its http step success, no egress needed", + "ref": "#24036" } ], "enumSource": { @@ -536,7 +550,7 @@ "title": "Every flow trigger type fires and produces a run of its bound flow; the run's trigger block records the launch event", "since": "v15", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -545,7 +559,8 @@ "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)" + "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)", + "⚠️ the schedule and time_relative legs need package-authored scheduled work switched ON at boot: export OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 (with OS_PORT). On a default boot both showcase flows are unbound by deployment policy — the banner says \"1 flow declares a 'schedule' trigger but is NOT bound — disabled by deployment policy\" (and the same for 'time_relative'), and GET /api/v1/automation/_status reports bound:false with that reason — so no tick and no sweep ever comes, which is the policy working, not a trigger defect" ] }, "variants": [ @@ -557,9 +572,9 @@ "time_relative" ], "steps": [ - "boot showcase isolated (dogfood §0); sign in as the dev admin", + "boot showcase isolated (dogfood §0) with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 and OS_PORT exported (fixtures.requires — without the first, the schedule and time_relative variants can never fire); 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", + "record_change (update leg): POST a normal-priority task, then PATCH priority→'urgent'; also move a task to done — create it with status 'in_review' and its required project (task_status_flow allows done only from in_review, and a create is not transition-checked), then PATCH 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: t=,v1=.', key 'showcase-webhook-secret'> — signHttpBodyAt(rawBody, 'showcase-webhook-secret', Math.floor(Date.now() / 1000)) from @objectstack/core writes it, and the door refuses a t more than 300 s from the server clock — 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", @@ -642,6 +657,12 @@ "date": "2026-10-11", "change": "api step: the intake recipe signs with the timestamped x-objectstack-signature form t=,v1= that signHttpBodyAt in @objectstack/core writes (HMAC-SHA256 of '.' under the secret; the door refuses a t more than 300 s from its clock), no longer with the body-only form. Guidance step of the inbound-hook convergence; the door's refusal of the body-only form is the follow-up card", "ref": "#22825" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "the schedule and time_relative legs assumed scheduled work fires on a stock boot (#23878 automation slice finding 1; #23879 out_of_scope_findings). Re-measured on main c91af8ed6: a default boot leaves both flows unbound by deployment policy (banner lines, _status bound:false, zero digest runs), while a boot with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 binds them (21 of 32 flows bound instead of 19) and showcase_scheduled_digest ticked three times 60 s apart with trigger.type 'schedule'. The fixture line and step 1 now name the switch. Step 3's status→done patch is also walked through in_review with a project: from the default backlog it answers 400 invalid_transition, and without a project the create answers 400", + "ref": "#24036" } ] }, @@ -650,7 +671,7 @@ "title": "A failing node inside try_catch is handled (catch region, $error binding) and the failed attempt is still recorded; outside it fails the run loudly", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -665,7 +686,7 @@ }, "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", + "handled case: POST a showcase_task carrying its required `project` (a seeded showcase_project id; without it the create answers 400 VALIDATION_FAILED 'Project is required'), status 'in_review' and an assignee, then PATCH status→'done' (allowed from in_review only) — 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 the overall status, the try_catch container step's own status, EVERY step carrying regionKind='try' (nodeId, status, error, retryAttempt) in log order, 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. Fire it from a TRIGGER (a record-change specimen, or a shipped trigger-fired flow such as showcase_declarative_connector_ping / showcase_task_completed_rest_ping) — ⛔ NOT via POST /api/v1/automation//trigger, because clause 7 cannot observe an api-triggered failure: the ERROR line exists precisely because no caller holds the result, and a manual/api trigger returns the envelope to its caller. Use the api-triggered run for clauses 5-6 (run status and run-level error) if convenient, but score clause 7 from a trigger-fired specimen", @@ -751,7 +772,8 @@ "change": "recorded the #7546 ruling. The handled-case clause asserted that the try-region's failing step appears with regionKind='try' — a contract the engine had never promised, so QA run #7516 read a by-design silence as a FAIL. Rather than weaken the clause to match the engine, the ruling implemented it: failed try attempts are now surfaced. The clause set is re-cut accordingly — container 'success' pinned as by-design (Option C's 'recovered' status was considered and rejected), the failed-attempt steps promoted to their own clause with the ordering requirement, and the retry evidence upgraded from the weak duration oracle to a retryAttempt count with duration as cross-check. The negative now names post-#7546 silence as the regression shape and forbids re-filing the container's 'success'", "ref": "#7546 (ruling 2026-08-11; QA run #7516; #1479 splice plumbing)" }, - { "revision": 3, "date": "2026-08-18", "change": "re-pointed step 5 and clause 7 at a trigger-fired specimen. The step prescribed an api-triggered probe, whose failure clause 7 structurally cannot observe: service-automation/src/engine.ts emits the ERROR line only inside trigger.start's callback, and its own text and #4632 verdict give the reason — 'no caller holds this result'. For a manual or api trigger the caller DOES hold it, so no line is written and a clean log reads as a missing diagnostic (#9467 CF-5)", "ref": "#9386" } + { "revision": 3, "date": "2026-08-18", "change": "re-pointed step 5 and clause 7 at a trigger-fired specimen. The step prescribed an api-triggered probe, whose failure clause 7 structurally cannot observe: service-automation/src/engine.ts emits the ERROR line only inside trigger.start's callback, and its own text and #4632 verdict give the reason — 'no caller holds this result'. For a manual or api trigger the caller DOES hold it, so no line is written and a clean log reads as a missing diagnostic (#9467 CF-5)", "ref": "#9386" }, + { "revision": 4, "date": "2026-10-16", "change": "the handled case's 'POST a showcase_task and PATCH status→done' failed as written (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 2 now creates the task with project and status 'in_review'; on that path showcase_resilient_sync ran and completed (the catch region handled the failure)", "ref": "#24036" } ] }, { @@ -1059,7 +1081,7 @@ "title": "connector_action dispatches through every registered connector kind, and the registry feeds the designer pickers", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": [ @@ -1074,7 +1096,7 @@ ], "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", - "`showcase_declarative_connector_ping` failing every record-change firing with `connector_action(showcase_status_api.request) failed: fetch failed` is a BOOT MISCONFIGURATION, not a blocked environment and not a missing stub. The showcase's declarative connector instances dial the running server itself via resolveShowcaseSelfUrl() (examples/app-showcase/src/system/self-url.ts), which reads SHOWCASE_SELF_URL -> OS_PORT -> PORT -> the literal http://127.0.0.1:3000 from the ENVIRONMENT. The CLI's `-p` flag only takes its DEFAULT from OS_PORT and never writes it back (packages/cli/src/commands/serve.ts), so a dogfood-§0 isolated boot on a non-3000 port leaves the connectors dialing 3000 while the server listens elsewhere. Export OS_PORT= (or SHOWCASE_SELF_URL) alongside -p and the variant runs. ⚠️ The string is indistinguishable from a sandbox egress block — that is what makes it expensive: #7516 burned a run proving it was an address problem with a TCP forwarder on 3000, and #7538 fixed the resolution but cannot fix a boot that never exports the variable. Verify by reading the instance baseUrl out of GET /api/v1/automation/connectors BEFORE scoring the variant." + "`showcase_declarative_connector_ping` failing every record-change firing with `connector_action(showcase_status_api.request) failed: fetch failed` is a BOOT MISCONFIGURATION, not a blocked environment and not a missing stub. The showcase's declarative connector instances dial the running server itself via resolveShowcaseSelfUrl() (examples/app-showcase/src/system/self-url.ts), which reads SHOWCASE_SELF_URL -> OS_PORT -> PORT -> the literal http://127.0.0.1:3000 from the ENVIRONMENT. The CLI's `-p` flag only takes its DEFAULT from OS_PORT and never writes it back (packages/cli/src/commands/serve.ts), so a dogfood-§0 isolated boot on a non-3000 port leaves the connectors dialing 3000 while the server listens elsewhere. Export OS_PORT= (or SHOWCASE_SELF_URL) alongside -p and the variant runs. ⚠️ The string is indistinguishable from a sandbox egress block — that is what makes it expensive: #7516 burned a run proving it was an address problem with a TCP forwarder on 3000, and #7538 fixed the resolution but cannot fix a boot that never exports the variable. Verify the boot's exported OS_PORT and one completed declarative run BEFORE scoring the variant (step 3): GET /api/v1/automation/connectors carries no baseUrl, and /api/v1/meta/connector/ shows the build-time literal, not the resolved address." ] }, "variants": [ @@ -1086,9 +1108,9 @@ "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)", - "premise guard, before scoring any dispatch clause: read the two declarative instances' resolved baseUrl out of that same response and confirm it names the port this boot is listening on — a baseUrl of http://127.0.0.1:3000 against a server on another port means the fixture is misconfigured, and every dispatch below will fail `fetch failed` for that reason alone", - "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", + "premise guard, before scoring any dispatch clause: confirm the boot EXPORTED OS_PORT= (or SHOWCASE_SELF_URL) — read it back from the server process's environment — and that one declarative dispatch succeeds (after step 4's create, the newest showcase_declarative_connector_ping run is completed, not 'fetch failed'). ⛔ Neither surface you might reach for carries the dial address: GET /api/v1/automation/connectors exposes no baseUrl at all (its rows carry name, label, type, description, icon, origin, state, actions), and GET /api/v1/meta/connector/showcase_status_api serves the BUILD-time providerConfig.baseUrl — http://127.0.0.1:3000 on an artifact compiled without OS_PORT, even while the running instance dials the exported port", + "POST a showcase_task carrying its required `project` (a seeded showcase_project id; without it the create answers 400 VALIDATION_FAILED 'Project is required') and status 'in_review' — fires showcase_declarative_connector_ping and showcase_mcp_connector_echo on create", + "PATCH the task status→'done' (allowed from in_review; from the default backlog task_status_flow answers 400 invalid_transition) — 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" @@ -1144,6 +1166,12 @@ "date": "2026-08-21", "change": "recorded the self-URL boot fact that makes the declarative-rest variant look permanently broken. The 17.1.0 sweep observed showcase_declarative_connector_ping failing `fetch failed` on every firing and read it as a network-restricted runner, i.e. as a fixture/environment gap needing a declared stub. Re-derived from source: it is an ADDRESS problem an isolated boot creates for itself — resolveShowcaseSelfUrl reads OS_PORT/PORT from the environment while the CLI's -p flag only reads that variable as a default and never exports it, so every non-3000 isolated boot dials 3000. The variant is fully runnable with OS_PORT exported; recording it as blocked(environment) or building a stub would have waived a working clause. Added the boot requirement and a pre-scoring baseUrl check, plus a knownGap carrying the discriminator so the next runner does not re-derive it (#10236 A5/B3)", "ref": "#10236" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "the premise-guard step and knownGaps[1] read the declarative instances' baseUrl out of GET /api/v1/automation/connectors, a key that response does not carry (#23878 automation slice finding 4). Re-measured on main c91af8ed6: the connector rows carry no baseUrl, and /meta/connector/showcase_status_api serves providerConfig.baseUrl http://127.0.0.1:3000 (the build-time artifact) while the boot exported OS_PORT=48731, nothing listened on 3000 and both declarative pings completed. The guard now checks the exported variable and a completed dispatch. Steps 4-5 also create the task with its required project and status 'in_review' before patching it to done (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows", + "ref": "#24036" } ] }, @@ -1245,7 +1273,7 @@ "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": 2, + "revision": 3, "priority": "P2", "surface": "api", "personas": [ @@ -1259,13 +1287,13 @@ }, "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)", + "baseline: POST /api/v1/data/showcase_task with priority='urgent' and its required project (a seeded showcase_project id); 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)", + "POST another priority='urgent' showcase_task (with its project); 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", + "POST a third priority='urgent' showcase_task (with its project); 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": [ @@ -1322,6 +1350,12 @@ "date": "2026-08-26", "change": "re-sourced, semantics untouched: the source cited 'toggleFlow → flowEnabled', and that process-local map is retired by ADR-0126 §7.2 (the #10243 cross-tenant leak's mechanism) — toggleFlow now writes the sys_metadata_activation row first and the engine projects the ledger via flowLedgerDisabled (engine.ts). Every clause here still holds and still means the same thing (single-boot kill switch: OFF unbinds, /_status mirrors, ON restores); the new durable/ledger-row/restart halves are NOT folded in — they are automation.packaged-flow-disable-durable, and the §7.3 subflow-disable guard is automation.packaged-flow-subflow-disable-refusal, both cross-referenced from source", "ref": "#12438" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "the urgent-task creates carried no project, so each answered 400 VALIDATION_FAILED 'Project is required' and no run could be counted (#23878 automation slice finding 2, re-measured on main c91af8ed6). Steps 2, 6 and 8 now send the project; with it the urgent create answered 201 and showcase_urgent_task_alert gained one run", + "ref": "#24036" } ] }, @@ -1330,7 +1364,7 @@ "title": "The flow-dispatch HTTP status contract: every never-dispatched, ran-and-failed and non-terminal outcome answers its own status + ADR-0112 code, at every door that dispatches a flow", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -1381,7 +1415,7 @@ { "clause": "the 400 FLOW_FAILED row carries the author's own message and the run's per-node accounting where the door documents them — error.details.errorMessage and error.details.summary — and not only a generic message", "oracle": "api", - "verify": "the trigger door's 400 body carries both keys; ADR-0112 envelopes carry no `data`, so a producer building its message out of result.error alone drops the author's words silently. #9585 extended this to /actions for this row only, via the typed FlowActionRefusal carrier — so both doors ship the pair HERE while the mechanism stays each door's own", + "verify": "the trigger door's 400 body carries both keys; ADR-0112 envelopes carry no `data`, so a producer building its message out of result.error alone drops the author's words silently. #9585 extended this to /actions for this row only, via the typed FlowActionRefusal carrier — so both doors ship the pair HERE while the mechanism stays each door's own. details.errorMessage is the flow AUTHOR's own failure text (flow.errorMessage): the qa-flow-status-doors recipe's qa_always_fails declares one for exactly this clause — a flow that declares none answers details {summary} only, which is correct and is not this clause's failure", "evidence": "the 400 bodies from both doors, keys shown" }, { @@ -1444,6 +1478,12 @@ "date": "2026-08-21", "change": "new — the automation area asserted flow BEHAVIOUR but never the status a caller observes, so a regression reverting any of 17.1.0's three BREAKING status changes left the whole area green (found by the 17.1.0 post-release sweep, #10225). The matrix is derived from the three changesets' own status tables and reconciled against the single runtime definition in flow-dispatch-status.ts, which is wider than the changesets: the same table is now read by /actions, the MCP run_action bridge and declared endpoints, and it carries a sixth NON-TERMINAL row (PAUSED, #9510) the changesets do not tabulate. The 422 arm was expected to be a knownGap; it is not — FlowSchema requires no start node (flow.zod.ts, no.min(1), no refinement) and nothing refuses a start-less flow at publish, so the row is authorable through a scratch package and is provisioned by qa-flow-status-doors instead of waived", "ref": "#10236" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "clause 4 asked for error.details.errorMessage from the qa-flow-status-doors recipe's qa_always_fails, which declared no errorMessage, so the key could never appear (#23878 automation slice finding 3). The recipe's step 3 now authors one, and the verify says where the key comes from. Re-measured on main c91af8ed6: without errorMessage the trigger answers 400 FLOW_FAILED with details {summary}; with the top-level errorMessage it answers 400 FLOW_FAILED with details.errorMessage and details.summary", + "ref": "#24036" } ] }, @@ -1452,7 +1492,7 @@ "title": "Disabling a packaged flow writes a durable sys_metadata_activation row that survives a cold restart with the trigger left unbound; re-enable UPDATES the row and restores firing", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P0", "surface": "api", "personas": [ @@ -1472,7 +1512,7 @@ "toggle OFF: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false}", "read the ledger row over the data API: GET /api/v1/data/sys_metadata_activation (reads are open — apiMethods ['get','list'], sys-metadata-activation.object.ts) and locate the row where metadata_type='flow' AND name='showcase_urgent_task_alert' — record active, package_id and the row id, plus the row's whole KEY SET: organization_id is ABSENT from it, because the table carries no tenant column (#15024 / ADR-0131 D7). ⛔ Do not record the tenant half as a value — `row.organization_id ?? null` answers `null` for a column that does not exist, so a value read passes while measuring nothing", "probe the refusal MESSAGE: POST /api/v1/automation/showcase_urgent_task_alert/trigger — record the full error body (status, code, message)", - "cold restart: stop the server process entirely; boot a second server over the SAME database file; read the boot log for the automation plugin's activation-ledger line", + "cold restart: stop the server process entirely; boot a second server over the SAME database file WITH --log-level info (or OS_LOG_LEVEL=info) — the activation-ledger line is logged at INFO and `os dev` defaults to warn (packages/cli/src/utils/log-level.ts DEFAULT_LOG_LEVEL), so a default-level boot never prints it; read the boot log for the automation plugin's activation-ledger line", "post-restart: GET /api/v1/automation/_status — record enabled/bound for showcase_urgent_task_alert", "post-restart kill-switch: POST another priority='urgent' showcase_task; GET /api/v1/automation/showcase_urgent_task_alert/runs — assert NO new run row", "re-enable: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": true}; re-read the sys_metadata_activation row by the id recorded in step 4", @@ -1494,7 +1534,7 @@ { "clause": "the disable survives a literal cold restart with the trigger left UNBOUND: on the new process /_status reports enabled=false AND bound=false for the flow, and the boot log carries the activation-ledger line naming it", "oracle": "api", - "verify": "post-restart GET /api/v1/automation/_status shows enabled:false, bound:false — the boot flow pull re-registers and re-arms every flow, and registerFlow deliberately does NOT re-arm a ledger-disabled one (engine.ts); hydrateFlowActivations then runs AFTER the pull (service-automation/src/plugin.ts) and disarms from the ledger (engine.ts). Corroborate with the log line '[Automation] Activation ledger: 1 packaged flow(s) are switched off…' naming showcase_urgent_task_alert", + "verify": "post-restart GET /api/v1/automation/_status shows enabled:false, bound:false — the boot flow pull re-registers and re-arms every flow, and registerFlow deliberately does NOT re-arm a ledger-disabled one (engine.ts); hydrateFlowActivations then runs AFTER the pull (service-automation/src/plugin.ts) and disarms from the ledger (engine.ts). Corroborate with the log line '[Automation] Activation ledger: 1 packaged flow(s) are switched off…' naming showcase_urgent_task_alert — an INFO line, so it is only in a boot log written at --log-level info or lower; its absence from a default (warn) boot is not evidence of anything", "evidence": "the post-restart /_status read + the boot-log excerpt" }, { @@ -1545,6 +1585,12 @@ "date": "2026-09-04", "change": "re-grounded on the tenantless ledger — the durability contract is UNCHANGED (toggle, cold restart, unbound trigger, update-never-delete all stand) and only what the row read records moved. #15024 dropped sys_metadata_activation's reserved organization_id column before it ever shipped, so three lines here described a column that is not there. (1) Step 4 listed organization_id among the fields to capture and the row clause asserted it NULL; both now read the row's KEY SET, because the value spelling passes VACUOUSLY once the column is gone (`row.organization_id ?? null` is `null` either way) — the leg was green for exactly the reason it should have been red, the same inversion #15024 applied to the dogfood pin. (2) The gloss '§5 reserves the column' is wrong on the DECISION, not just the value: ADR-0131 D7 WITHDREW ADR-0126 §5 D3's reserved-and-never-written column ('a reserved nullable tenant column is the shape D1 forbids') and names this table as reverted before 17.3 and not returning, so the old wording told a runner the tenant dimension was coming back. (3) The verify line named the NULL-collapsed 'organization' index, and the source line still cited 'org-carrying rows skipped on read' plus 'organization_id never written' — the index is a plain unique:'global' over the two required key parts (no NULL arm left to collapse), and the read-side skip is retired, not merely unexercised. The store citation is split so #list carries that correction where the read path actually lives, and ADR-0131 D7 is now cited directly", "ref": "#15253" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "clause 3's log corroboration reads an INFO line that a default os dev boot never prints (#23878 automation slice finding 5): the CLI's default log level is warn. Step 6 now boots the second server with --log-level info, and the verify says the line is INFO. Re-measured on main c91af8ed6: after toggling showcase_urgent_task_alert off and cold-restarting at OS_LOG_LEVEL=info over the same file DB, the log carries INFO '[Automation] Activation ledger: 1 packaged flow(s) are switched off for this installation and were left unbound — 'showcase_urgent_task_alert'.' and /_status reports enabled:false, bound:false; re-enable answers 200", + "ref": "#24036" } ] }, @@ -1553,7 +1599,7 @@ "title": "Disabling a packaged flow that packaged callers invoke as a subflow is refused 409 DELETE_RESTRICTED naming every caller; the refused attempt writes no ledger row; enabling the subflow itself is never guarded", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1573,7 +1619,7 @@ "boot showcase isolated (dogfood §0, OS_PORT exported); sign in as the dev admin", "subflow pair: POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} — record the full HTTP answer (status, code, message)", "map pair: POST /api/v1/automation/showcase_one_task_signoff/toggle {\"enabled\": false} — record the answer", - "after each refusal: GET /api/v1/data/sys_metadata_activation and assert NO row exists for the refused flow; then prove it is still ARMED — trigger a run through its caller (PATCH a showcase_task to status='done' for the notify pair) and confirm the child's subflow step executed", + "after each refusal: GET /api/v1/data/sys_metadata_activation and assert NO row exists for the refused flow; then prove it is still ARMED — trigger a run through its caller (for the notify pair: create a showcase_task with its required project and status 'in_review', then PATCH it to status='done' — task_status_flow allows done only from in_review) and confirm the child's subflow step executed", "remedy sequence (the refusal's own instruction): POST /api/v1/automation/showcase_task_done_notify_owner/toggle {\"enabled\": false} (the CALLER — it has no packaged callers of its own, so this lands), then RETRY POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} and record what it answers", "enable arm, child FIRST: POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": true} while its caller is still off (confirm it is never refused), then toggle showcase_task_done_notify_owner back ON, then repeat the child's enable with the caller armed (a no-op enable). ⚠️ Enabling the CALLER while its packaged subflow is still disabled is refused by design since 679f95e (#20711) — 409 RESOURCE_CONFLICT 'cannot be enabled while 1 packaged subflow it calls is disabled … Enable it first' — so the caller-first order is not a fail of this item", "teardown: re-enable anything left disabled (or discard the isolated DB)" @@ -1635,7 +1681,8 @@ "change": "new — the ADR-0126 §7.3 disable guard had only engine-level unit pins and no HTTP-level assertion anywhere: the wire 409 DELETE_RESTRICTED with callers named, the map-caller arm, the no-row-on-refusal invariant, and the never-guarded enable. The remedy-sequence clause (disable the caller first) is authored as an expected fail: the shipped scan consults registration only (engine.ts), so the refusal's own named remedy does not currently unblock the child — the clause keeps the promise the door itself makes and flags the red as a tracked product finding", "ref": "#12438" }, - { "revision": 2, "date": "2026-10-02", "change": "retired clause 5's expected-fail note and re-ordered step 6. The remedy sequence now holds (36d043b #20724, 0d9349f #20759): on 17.6.0 the caller's disable then the child's retry both answer 200 and the ledger reads active=false. Step 6's caller-first enable is refused by design since 679f95e (#20711, 409 RESOURCE_CONFLICT naming the disabled subflow), so the step enables the child first and clause 4 is scoped to a child's enable. Measured by the 17.6.0 release-verification run", "ref": "#21330" } + { "revision": 2, "date": "2026-10-02", "change": "retired clause 5's expected-fail note and re-ordered step 6. The remedy sequence now holds (36d043b #20724, 0d9349f #20759): on 17.6.0 the caller's disable then the child's retry both answer 200 and the ledger reads active=false. Step 6's caller-first enable is refused by design since 679f95e (#20711, 409 RESOURCE_CONFLICT naming the disabled subflow), so the step enables the child first and clause 4 is scoped to a child's enable. Measured by the 17.6.0 release-verification run", "ref": "#21330" }, + { "revision": 3, "date": "2026-10-16", "change": "the 'still armed' probe patched a showcase_task to done without saying how to get there (#23878 automation slice finding 2, re-measured on main c91af8ed6): showcase_task requires `project` (a create without it answers 400 VALIDATION_FAILED 'Project is required'), and task_status_flow allows status→done only from in_review (a PATCH from the default backlog answers 400 invalid_transition). A create with project and status 'in_review' answers 201 (create is not transition-checked) and the PATCH to done answers 200, firing the record-change flows. Step 4 now creates the task with project and status 'in_review' first; on that path showcase_task_done_notify_owner ran and completed", "ref": "#24036" } ] }, { @@ -1643,7 +1690,7 @@ "title": "POST /automation/:name/clone: whole-definition copy mutating exactly name/label/status, protection envelope dropped, no ancestry, draft-but-armed (double-fire notice verbatim), 409/404/400 refusal arms — and the honest clause: the clone must survive a restart and reach Studio", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1664,7 +1711,7 @@ "double-fire: POST /api/v1/data/showcase_task with priority='urgent'; GET both flows' runs lists", "references: POST /api/v1/automation/showcase_task_done_notify_owner/clone {\"name\":\"qa_task_done_clone\",\"label\":\"QA task-done clone\"}; read the clone's subflow node config", "ordinariness: PUT /api/v1/automation/qa_urgent_alert_clone with the clone's own definition (label tweaked) — it is a tenant flow, the edit lands", - "restart probe: stop the server; cold-boot over the SAME database file; GET /api/v1/automation/qa_urgent_alert_clone and GET /api/v1/meta/flow (does any package/tenant metadata hold the clone?)", + "restart probe: stop the server; cold-boot over the SAME database file; GET /api/v1/automation/qa_urgent_alert_clone and GET /api/v1/meta/flow (does any package/tenant metadata hold the clone?); then, in the browser as the admin, open /_console/studio/~org/automations (the 'Organization flows' scope) and confirm the clone is listed on the Automations rail and opens in the designer; create one more urgent task (with its required project) and confirm the clone's runs list grows on the new process", "teardown: DELETE /api/v1/automation/qa_urgent_alert_clone and …/qa_task_done_clone (or discard the isolated DB)" ], "acceptance": [ @@ -1707,7 +1754,7 @@ { "clause": "the honest clause — the clone OUTLIVES the process and REACHES Studio: after a cold restart over the same database file the clone still exists and dispatches, and it is listed on a Studio Automations rail (it is 'an ordinary org/install-owned flow', ADR-0126 §7.1, under the shipped 'customize in Studio' promise §1.3 — the Setup page's own copy says 'Editing happens in Studio')", "oracle": "api", - "verify": "step 8: GET /api/v1/automation/qa_urgent_alert_clone on the new process, and the flow present in metadata a Studio rail can list. DURABILITY holds since cb4c31d (#20907, with the clone save path #20761): the clone survives a cold restart and still dispatches. ⚠️ REACHABILITY is an EXPECTED FAIL, kept as the assertion on purpose: the Studio Automations rail lists PACKAGE-scoped metadata (objectui StudioDesignSurface.tsx loadPackageSurfaces(client,'flow',packageId)) while the clone is stored with package_id null (_packageId deliberately stripped, flow-clone.ts), so it is reachable in no rail — tracked at #21332. Record which half failed (durability, reachability, or both) — do NOT soften the clause to a same-process read", + "verify": "step 8: GET /api/v1/automation/qa_urgent_alert_clone on the new process, and the flow present in metadata a Studio rail can list. DURABILITY holds since cb4c31d (#20907, with the clone save path #20761): the clone survives a cold restart and still dispatches. REACHABILITY holds since objectui#11553 (the vendored console at f8267df93aba carries it; #21332 closed as a duplicate on 2026-10-03): Studio's org scope, /_console/studio/~org/automations ('Organization flows'), lists package-less flows on its Automations rail, so the clone stored with package_id null (_packageId deliberately stripped, flow-clone.ts) is listed there and opens in the designer. A clone absent from that rail, or a rail that opens another flow for the clone's deep link, fails the clause. Record which half failed (durability, reachability, or both) — do NOT soften the clause to a same-process read", "evidence": "the post-restart GET (present or 404) + the meta/flow listing showing where, if anywhere, the clone is held" } ], @@ -1727,7 +1774,7 @@ "packages/runtime/src/domains/automation.ts#clone (the clone arm: body-first validation, mandatory name+label, 404/409 probes, registerFlow-only registration, no ancestry on the response), (clone in the manage_metadata write set)", "packages/runtime/src/domains/automation-flow-clone.test.ts#dispatcher (route-level pins for the validation and refusal arms — a mocked-service pin; it cannot see the live dispatcher, the double-fire, or the restart — which is exactly the remainder this item drives)", "packages/spec/src/automation/flow.zod.ts (machine-name regex)", - "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (the Automations rail is package-scoped — the reachability half of the honest clause)", + "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx + studioScope.ts (the org scope ~org that lists package-less flows; objectui#11553, pinned by StudioDesignSurface.packageLessFlows-11553.test.tsx) — the reachability half of the honest clause", "examples/app-showcase/src/automation/flows/index.ts#UrgentTaskAlertFlow (UrgentTaskAlertFlow; TaskDoneNotifyOwnerFlow → showcase_notify_owner)", "#12438 (sweep) · Epic #12150", "cross-ref: access-security.packaged-flow-write-door-parity (owns the packaged-base write-door question this item's ordinariness clause must not be read as answering); automation.setup-packaged-automation-board (the clone dialog UI half)" @@ -1739,7 +1786,8 @@ "change": "new — the ADR-0126 §7.1 clone door landed with route-level mocked pins only; nothing asserted the live contract (double-fire with the notice verbatim, references not re-pointed, the refusal arms on the real dispatcher) and nothing anywhere asked the two questions the honest clause pins: the clone registers engine-only (no sys_metadata write) and its _packageId is stripped, so restart survival and Studio reachability — both halves of the ADR's own 'ordinary flow' promise — are authored as an expected-fail probe rather than left unasked", "ref": "#12438" }, - { "revision": 2, "date": "2026-10-02", "change": "clause 7's expected-fail note now names only the reachability half. The durability half was fixed by cb4c31d (#20907, with #20761): on 17.6.0 the clone survives a cold restart, reads back with its edited label and still fires (release-verification run). The reachability half still fails — no Studio surface lists a package-less flow — and is now filed as #21332 instead of resting on the #12438 sweep", "ref": "#21330" } + { "revision": 2, "date": "2026-10-02", "change": "clause 7's expected-fail note now names only the reachability half. The durability half was fixed by cb4c31d (#20907, with #20761): on 17.6.0 the clone survives a cold restart, reads back with its edited label and still fires (release-verification run). The reachability half still fails — no Studio surface lists a package-less flow — and is now filed as #21332 instead of resting on the #12438 sweep", "ref": "#21330" }, + { "revision": 3, "date": "2026-10-17", "change": "clause 6's reachability EXPECTED FAIL is stale (checklist-accuracy finding, automation slice report on #23878): objectui#11553 gave Studio an org scope whose Automations rail lists package-less flows, and #21332 closed as a duplicate. The verify now asserts the clone is listed and opens at /_console/studio/~org/automations, steps[7] drives it after the restart, and the objectui source line names the org scope and its pin. Re-run on main c91af8ed6 with the console at objectui f8267df93aba, file DB: POST …/showcase_urgent_task_alert/clone {name qa_urgent_alert_clone, label} → 200 status draft; the Organization flows rail listed 'QA urgent alert clone' and opened it (Trigger: Record created or updated · showcase_task, Enabled, no lock banner); after a cold restart over the same DB the GET answered 200, the rail listed and opened it again, and one urgent task create added one run to the clone (0 → 1) and one to the base (1 → 2); teardown DELETE → deleted true", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index fbba946a953..2a39e6e79bb 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -9,7 +9,7 @@ "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": 4, + "revision": 5, "priority": "P0", "surface": "cli", "personas": ["operator (local shell)"], @@ -88,7 +88,7 @@ "OS_DATABASE_URL / DATABASE_URL / TURSO_DATABASE_URL env (env wins over the default)", "-d :memory: (SQLite's own in-memory database — the replacement for the retired in-memory engine; nothing persists across restarts)", "--database-driver memory / OS_DATABASE_DRIVER=memory|mingo|in-memory / -d memory:// or mingo:// → REFUSED (the retired in-memory engine; see the negative for where each is refused and what it names)", - "config-declared default datasource (the project config's default home wins over the unified default)", + "config-declared default datasource (the project config's default home wins over the unified default) — authored as a NAMED datasource plus a datasourceMapping rule { default: true, datasource: NAME } (readConfigDeclaredDefault); a datasource literally named `default` is refused by defineStack as reserved for the host's primary, and a mapping rule naming 'default' is declined, so neither can stand for this variant", "legacy compat-read: an existing dev.db / standalone.db when no objectstack.db exists (resolved WITH one loud notice line; probe order objectstack.db → dev.db → standalone.db)" ], "traps": ["stale-dist", "stale-console-bundle", "shared-browser-tab"], @@ -112,6 +112,12 @@ "date": "2026-10-03", "change": "the in-memory (mingo) engine was retired as a boot store, so the DB-selection matrix loses its 'memory driver imposes no file default' tier: the variant now reads REFUSED, a negative names where each spelling is refused (flag at parse, env before the Database line, URL in the serve child) and what the refusal names, `-d :memory:` is added as the replacement variant, and the resolveProjectDatabaseUrl source note drops the memory rung. Measured on the change's own build: `--database-driver memory` exit 2 (oclif allowlist), `OS_DATABASE_DRIVER=memory os dev --fresh` exit 1 with no Database line, `os dev -d :memory:` serves crm_lead 200", "ref": "#21492" + }, + { + "revision": 5, + "date": "2026-10-17", + "change": "the config-declared-default variant named no authorable shape, and the obvious one fails (checklist-accuracy finding, #23878 close-out): a datasource literally named `default` is refused by defineStack, so the variant is a named datasource routed by datasourceMapping { default: true, datasource: NAME }, the shape readConfigDeclaredDefault reads. Re-run of that variant on main c91af8ed6 on a minimal scratch stack: datasources [{ name: 'qa_main', driver: 'sqlite', config: { filename: SCRATCH/data.db } }] with the default mapping rule, os dev -p PORT with no -d and no env database → '🗄️ Database: file:SCRATCH/data.db', and no .objectstack/data file was created; a datasource named 'default' → build exit 2 with the reserved-name refusal", + "ref": "#24036" } ] }, @@ -120,7 +126,7 @@ "title": "os build's own contract: no computed value in the exit slot, located errors for schema and author-time rule failures, artifact + stats output, warnings never flip the exit", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "build", "personas": ["operator (local shell)"], @@ -136,7 +142,7 @@ "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", + "restore the config and add one UNDECLARED authoring key (the #3786 advisory class) where it is still an advisory: object, field, report and enable shapes are CLOSED, so an unknown key ON a field is a hard refusal (exit 2 out of defineStack, exit 1 'Validation failed … unrecognized_keys' under strict:false) — that is clause 2's territory, not this one. The advisory survives only at a nested strip-mode site, e.g. inside a field's object-form visibleWhen ({ dialect: 'cel', source: …, qaUndeclared: 1 }). Build it under defineStack(…, { strict: false }) and run `os build; echo $?` — the 'Undeclared authoring keys (1) — dropped at load; reported here, never refused' block prints and the build passes; under the default defineStack the same key draws defineStack's own one-line warning and exit 0, without the compile-side block", "the exit-slot pair: make the config throw at LOAD time and run BOTH `os build; echo $?` (human path — expect exit 2 out of compile.ts's human-path catch: its own ✗ line, then this.exit(2)) and `os build --json; echo $?` (expect 1 via emitJson) — the pair that separates the framework's error exit from the #4873 leak", "repeat one successful run and one failing run verbatim and compare the two exit codes each time — a code that MOVES between identical runs is the #4873 signature, and only a repeat can see it", "confirm `os build` and `os compile` produce identical behavior on the same input (build is the documented alias)" @@ -175,7 +181,7 @@ { "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", + "verify": "the #3786 warning block prints for the nested strip-mode key built under strict:false, the artifact is written with the key gone, echo $? is 0. An unknown key on a closed shape (a field) is refused instead and does not satisfy this clause", "evidence": "warning output + exit code + artifact" } ], @@ -196,7 +202,8 @@ { "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" }, { "revision": 2, "date": "2026-08-11", "change": "narrowed the exit-code clause to the #4873 duration-leak it exists to pin. As phrased ('exactly 0 or 1') it also forbade oclif's standard ExitError 2, which compile.ts's human-path this.error() catches legitimately produce on a config-load-time throw — so it contradicted cli.flag-command-error-ux, which asserts that same 2 is correct, and a run reported the collision as a product violation (#7648). The clause now pins what #4873 was: a computed value (duration/count) reaching the exit slot, detectable as a nonzero on success or a code that moves between identical runs; 0/1 via CliExitCode and oclif's fixed 2 on the human path are named as legitimate. Still load-bearing — a duration in the exit slot FAILS it — with a repeat-run step and a negative added to keep it falsifiable, and the CliExitCode source note corrected to say it narrows the emitJson/emitText slot, not oclif's error exit", "ref": "#7648" }, { "revision": 3, "date": "2026-08-18", "change": "reconciled clause 2 with clause 5, which contradicted each other. Clause 2 demanded exit 1 on a schema violation while clause 5 explicitly blesses 'an oclif 2 from a this.error() path'. Measured in compile.ts (build is an alias of it): the --json failure path emits the payload and calls this.exit(1); the human path falls through the same catch to this.error(), which exits 2. The clause now names both and asks which was run (#9417)", "ref": "#9386" }, - { "revision": 4, "date": "2026-10-03", "change": "text refresh only, no status change: six live texts named compile.ts's this.error() as the mechanism of the human path's exit 2. Since bf36edd0a1 that catch prints its own ✗ line and ends in this.exit(2), which raises the same status without oclif's second Error: rendering; every oracle (exit 2 on the human path, 1 under --json) still holds. Revisions 2 and 3 above keep their wording: they record what was true when written", "ref": "#21492" } + { "revision": 4, "date": "2026-10-03", "change": "text refresh only, no status change: six live texts named compile.ts's this.error() as the mechanism of the human path's exit 2. Since bf36edd0a1 that catch prints its own ✗ line and ends in this.exit(2), which raises the same status without oclif's second Error: rendering; every oracle (exit 2 on the human path, 1 under --json) still holds. Revisions 2 and 3 above keep their wording: they record what was true when written", "ref": "#21492" }, + { "revision": 5, "date": "2026-10-17", "change": "steps[5] re-pointed (checklist-accuracy finding, #23878 close-out): as written ('add one undeclared authoring key') it is now a hard refusal, because object, field, report and enable shapes are closed, so it could only fail clause 5 for the wrong reason. The step now places the key at a nested strip-mode site (a field's object-form visibleWhen) under defineStack(…, { strict: false }) and names what the closed site does instead; clause 5's verify says the same. Re-run on main c91af8ed6 on a scratch stack: the key on a field → exit 2 'Unrecognized key(s) on this field: qaUndeclared' (default) and exit 1 'Validation failed … unrecognized_keys' (strict:false); the key inside visibleWhen under strict:false → exit 0 with '⚠ Undeclared authoring keys (1) — dropped at load; reported here, never refused' naming objects.qa_thing.fields.note.visibleWhen.qaUndeclared, and the artifact's visibleWhen without it; the same key under the default defineStack → exit 0 with defineStack's own one-line warning and no compile-side block", "ref": "#24036" } ] }, { @@ -402,7 +409,7 @@ "title": "The published first-run closes: create-objectstack scaffold → install → validate → build → boot → health, with the skills boundary holding", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": ["new user (published-registry consumer)"], @@ -421,7 +428,7 @@ "cd qa-first-run && 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 — `blank` only since revision 2: create-objectstack's catalog, packages/create-objectstack/src/template-registry.ts, holds the one bundled template); 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", + "skills: from the 18 line on (create-objectstack@next, and this checkout's bin for the pre-release variant) a scaffold WITHOUT --skip-skills installs the catalog from the @objectstack/skills package its own install put in node_modules, synced into .claude/skills/ as real files — compare that set (directories holding a SKILL.md, not symlinks) with node_modules/@objectstack/skills/dist/skills by name and count, as scaffold-e2e.yml's 'Skills: the scaffolder installed exactly the @objectstack/skills catalog' step does, and assert no internal skill (dogfood-verification) is among them. The 17.x line (create-objectstack@latest while 17.7 is latest) depends on no @objectstack/skills package. The GitHub-path boundary probe stays as the guard for the channel the docs keep: 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": [ @@ -446,7 +453,7 @@ { "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, by UNIQUE SKILL NAME, against the curated catalog — the installed set is the de-duplicated names of the directories holding a SKILL.md (as scaffold-e2e.yml computes it with sort -u), never a raw file or path count; grep for dogfood-verification in the discovery listing comes back empty", + "verify": "set-equality, by UNIQUE SKILL NAME, against the curated catalog — the installed set is the de-duplicated names of the directories holding a SKILL.md (as scaffold-e2e.yml computes it with sort -u), never a raw file or path count; grep for dogfood-verification in the discovery listing comes back empty; for the scaffolder's own install (18 line), the same set-equality is against the installed @objectstack/skills package's dist/skills catalog, never this checkout's skills/", "evidence": "the installed-set diff + discovery listing" }, { @@ -473,7 +480,8 @@ { "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" }, { "revision": 2, "date": "2026-08-14", "change": "variants narrowed to [blank]: the five remote content templates were delisted from the marketplace and retired from the create-objectstack catalog, and the scaffold-e2e registry-canary matrix this item mirrors was trimmed with them", "ref": "claude/issue-8677-retire-remote-template-catalog" }, { "revision": 3, "date": "2026-10-05", "change": "three corrections from run #21845. (1) The separate `npm install` step is dropped: the scaffolder already installs (pnpm when found, npm otherwise), and a trailing npm install fails on the pnpm layout on 17.6.0 and 17.7 alike, so the step measured itself rather than the product. The scaffolder's own exit code is now the install evidence, which is also what the published canary job in scaffold-e2e.yml does. Of the card's three options this one was picked as the least ambiguous: it does not depend on which package manager the host has, as `pnpm install` would, and it does not change the published scaffold command, as `--skip-install` would. (2) fixtures.requires gains a pre-release variant: pack the workspace to a localhost registry with no upstream for @objectstack/* and create-objectstack, run the repo's bin against it, and prove the candidate via lockfile integrity. The published path measures only what is released. (3) The skills-boundary clause compares unique skill names, not a raw count (card #21851 entries 8 and 9)", "ref": "#21845" }, - { "revision": 4, "date": "2026-10-13", "change": "acceptance[1].verify and steps[3] stop naming six templates (stale wording, #23166 C2; parked on #23121, carried by #23169). The variants have been [blank] since revision 2, when the five remote content templates were retired, and create-objectstack's template registry holds blank alone. QA run #23166 scored the published 17.7.0 path's one template. No clause's assertion changed", "ref": "#23169" } + { "revision": 4, "date": "2026-10-13", "change": "acceptance[1].verify and steps[3] stop naming six templates (stale wording, #23166 C2; parked on #23121, carried by #23169). The variants have been [blank] since revision 2, when the five remote content templates were retired, and create-objectstack's template registry holds blank alone. QA run #23166 scored the published 17.7.0 path's one template. No clause's assertion changed", "ref": "#23169" }, + { "revision": 5, "date": "2026-10-17", "change": "steps[4] described only the GitHub `skills add` route (checklist-accuracy finding, #23879): on the 18 line the scaffolder installs the catalog from the @objectstack/skills package in node_modules. The step now scores that install against the package's own catalog and keeps the GitHub-path probe as the docs-channel guard; the skills clause's verify names both comparisons. Re-run on 2026-10-17: `npx -y create-objectstack@next qa-first-run -t blank` (18.0.0-next.2) exit 0, '+ @objectstack/skills 18.0.0-next.2', 'Installing AI skills for your coding agent from @objectstack/skills@18.0.0-next.2', 'Found 10 skills in node_modules'; .claude/skills held 10 skill directories, no symlinks, set-equal to node_modules/@objectstack/skills/dist/skills (objectstack-ai, -api, -automation, -data, -formula, -i18n, -platform, -query, -ui, -upgrade, also this checkout's skills/objectstack-* set), no dogfood-verification. npm view: create-objectstack latest 17.7.0 with dependencies chalk and commander only; @objectstack/skills published only as 18.0.0-next.0 to next.2", "ref": "#24036" } ] }, { @@ -850,7 +858,7 @@ "title": "The documented newcomer verification loop closes on a FRESHLY SCAFFOLDED project: os validate passes and the Console actually paints at /_console/ — the seam between the scaffold item and the showcase boot item", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["a brand-new developer following content/docs/getting-started/quick-start.mdx, with no prior project"], @@ -861,13 +869,14 @@ ], "knownGaps": [ "This item deliberately does NOT re-prove what two existing items already cover. The scaffold→install→validate→build→artifact-boot chain belongs to cli.scaffold-first-run (automated by .github/workflows/scaffold-e2e.yml); the console-on-a-populated-app half belongs to cli.dev-boot-contract, which drives examples/app-showcase. What is untested is the SEAM: the Console rendering against a freshly scaffolded BLANK project — one object and no seed data. Score only the seam; cite the two siblings for the halves.", - "A blank scaffold DOES have a loginable identity out of the box: `seed-admin` is default-ON in dev (commands/dev.ts: flags['seed-admin'] ?? true, 'Default: on (idempotent — it acts only while no account holds the seed address and no local password login exists anywhere, never overwrites an existing account)'), so bare `objectstack dev` seeds admin@objectos.ai / admin123 on an empty DB and the banner prints the credentials. Clause 4 therefore scores whether the newcomer can COMPLETE the documented visual check with that identity, not whether one exists. Still do not import showcase credentials." + "A blank scaffold DOES have a loginable identity out of the box: `seed-admin` is default-ON in dev (commands/dev.ts: flags['seed-admin'] ?? true, 'Default: on (idempotent — it acts only while no account holds the seed address and no local password login exists anywhere, never overwrites an existing account)'), so bare `objectstack dev` seeds admin@objectos.ai / admin123 on an empty DB and the banner prints the credentials. Clause 4 therefore scores whether the newcomer can COMPLETE the documented visual check with that identity, not whether one exists. Still do not import showcase credentials.", + "the scaffold serves the console its OWN install put in node_modules (@objectstack/console from the registry at the scaffold's version), not this repository's vendored .objectui-sha bundle: the seam this item scores is the PUBLISHED console against a blank project, and a console change pinned in .objectui-sha but not yet released is not exercised here (cli.dev-boot-contract on examples/app-showcase is where the vendored bundle is served)" ] }, "steps": [ "scaffold a fresh project exactly as the published docs instruct: `npx -y create-objectstack@latest qa-console-first-paint; echo $?` (no -t flag — the docs never pass one; for an unreleased candidate use cli.scaffold-first-run's pre-release variant). The scaffolder installs ITSELF (pnpm when found, npm otherwise) and the docs prescribe no separate install — ⛔ do not add a trailing `npm install`: it fails on a pnpm-installed scaffold (17.6.0 and 17.7 alike)", "run the documented gate: `npx os validate; echo $?`", - "run `npm run dev` — the scaffold's OWN script, which is what a newcomer reaches for once the scaffold has installed — and probe http://localhost:3000/_console/", + "run `npm run dev` — the scaffold's OWN script, which is what a newcomer reaches for once the scaffold has installed — and probe http://localhost:3000/_console/. Port 3000 is the default; a runner confined to a port range exports OS_PORT and passes the port through the scaffold's own script (`npm run dev -- -p PORT`), then probes that port — the same substitution applies to step 4's `npx os dev --ui -p PORT`", "load the console in a browser and record what a newcomer actually sees: does the shell render, and is there any identity they can sign in with?", "stop; run the documented spelling `npx os dev --ui` and probe /_console/ again — the two must agree (see clause 3; `--ui` is a forwarder for a serve-side flag that is already default-on, so this is a no-op equivalence check, not a divergence hunt)", "capture the boot log for both invocations" @@ -925,7 +934,8 @@ "ref": "#9299" }, { "revision": 2, "date": "2026-08-18", "change": "corrected the stale 'no seeded admin unless one is requested' assumption in both knownGaps. commands/dev.ts resolves seed-admin as flags['seed-admin'] ?? true and documents it 'Default: on', so a bare `objectstack dev` on an empty DB seeds admin@objectos.ai / admin123 and prints it in the banner. Clause 4's open question ('is there any identity a newcomer can sign in with?') has a definite answer, and leaving the gap as written told the runner not to assume the answer the tree already fixes (#9467 CF-6)", "ref": "#9386" }, - { "revision": 3, "date": "2026-10-05", "change": "two corrections from run #21845. (1) Step 1's trailing `npm install` is dropped: the scaffolder already installs (pnpm when found, npm otherwise), the quick-start prescribes no separate install, and a trailing npm install fails on the pnpm layout on 17.6.0 and 17.7 alike. This matches the choice made on cli.scaffold-first-run, and the npm run dev step no longer presumes it. (2) acceptance[3] states its limit: a blank scaffold ships no app or action, so the docs' 'watch an action show/hide, confirm the nav and filters' half is recorded as not-applicable here, never as a FAIL. The record-create half stays scored. The clause names studio-authoring.first-run-loop as the in-console continuation and says outright that it covers the app/nav half and no action (card #21851 entries 8 and 10)", "ref": "#21845" } + { "revision": 3, "date": "2026-10-05", "change": "two corrections from run #21845. (1) Step 1's trailing `npm install` is dropped: the scaffolder already installs (pnpm when found, npm otherwise), the quick-start prescribes no separate install, and a trailing npm install fails on the pnpm layout on 17.6.0 and 17.7 alike. This matches the choice made on cli.scaffold-first-run, and the npm run dev step no longer presumes it. (2) acceptance[3] states its limit: a blank scaffold ships no app or action, so the docs' 'watch an action show/hide, confirm the nav and filters' half is recorded as not-applicable here, never as a FAIL. The record-create half stays scored. The clause names studio-authoring.first-run-loop as the in-console continuation and says outright that it covers the app/nav half and no action (card #21851 entries 8 and 10)", "ref": "#21845" }, + { "revision": 4, "date": "2026-10-17", "change": "two runner facts the item lacked (checklist-accuracy finding, #23878 close-out): it probes port 3000 with no port lever, and it did not say whose console the scaffold serves. steps[2] now names the OS_PORT / `npm run dev -- -p PORT` substitution (and step 4's), and a knownGap records that the scaffold serves its installed @objectstack/console, not the .objectui-sha bundle. Re-run of the port leg on 2026-10-17 from create-objectstack@next (18.0.0-next.2; latest was 17.7.0): `OS_PORT=48734 npm run dev -- -p 48734` → '➜ Console: http://localhost:48734/_console/', /api/v1/health 200 and /_console/ 200 on that port, nothing listening on 3000; node_modules/.pnpm carried @objectstack/console@18.0.0-next.2 and the page loaded its ./assets/index-*.js from it", "ref": "#24036" } ] }, { @@ -933,7 +943,7 @@ "title": "os doctor: every check emits an attributed row, an unparseable posture is an ERROR not a swallow, ledger rows print under every posture, the ✓ is withheld when the read was incomplete — and the two monorepo-anchored scans announce a named skip in a user app instead of blessing a tree they never walked", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], @@ -945,13 +955,13 @@ "a writable .objectstack/installed-packages/ in the scratch tree, so an unreadable ledger entry can be staged per run" ], "knownGaps": [ - "no stock fixture ships a config with an orphan view / circular lookup / unused object, so the config-analysis rows must be staged; record the staged shapes in evidence rather than skipping the rows silently", + "no stock fixture ships a config with an orphan view / circular lookup / unused object, so the config-analysis rows must be staged; record the staged shapes in evidence rather than skipping the rows silently. The staged config must still LOAD: when defineStack refuses it, doctor reports a 'Config load' warning row and skips the whole config-analysis block, so the three rows never print", "the config-analysis block runs only `if (configExists())` (doctor.ts) — a bare directory exercises the environment rows alone; run both shapes and label which produced which rows" ] }, "steps": [ "baseline: from the untouched scaffold run `os doctor; echo $?` and `os doctor -v; echo $?` — capture both full reports (the unconditional environment rows print first: Node.js, pnpm, TypeScript, Dependencies, Git, Environment files — then whichever finding-only rows are live, then the extended checks)", - "stage the config findings in the scratch copy: a view whose object does not exist (orphan), two objects whose lookup fields point at each other (circular), and one object nothing references (unused); run `os doctor` and capture the three warning rows — then restore and capture the three ✓ rows (Dependencies / Object usage / View integrity)", + "stage the config findings in the scratch copy: a view whose object does not exist (orphan), two objects whose lookup fields point at each other (circular), and one object nothing references (unused); run `os doctor` and capture the three warning rows — then restore and capture the three ✓ rows (Dependencies / Object usage / View integrity). Stage the orphan as a WELL-FORMED view container whose name equals its object key and whose object does not exist, e.g. views: [{ name: 'qa_missing', object: 'qa_missing', list: { columns: [...] } }]: the default defineStack load accepts it and all three checks run. A malformed view body (e.g. a single view's viewKind/config keys on a container) is refused by the default load, and doctor then prints '⚠ Config load Could not load config for analysis (config checks skipped)' and runs none of the three — a staging fault, not a doctor finding; defineStack(…, { strict: false }) loads any shape, and is the route when the staging itself needs a shape the closed schema refuses", "unparseable posture: `OS_TENANCY_POSTURE=isolatd os doctor; echo $?` — the Tenancy posture ERROR row must print naming the variable and the allowed set, the REST of the report must still run, and the exit must be 1 (#5382: report-not-refuse, resolved OUTSIDE the config-analysis try at doctor.ts, row pushed)", "env provenance: put the same bad posture in a .env FILE (not the shell) and re-run — the finding must still fire (#5387 dotenv cascade) and the Environment files row must attribute the value to the file that supplied it", "ledger: write .objectstack/installed-packages/broken.json holding non-JSON; run `os doctor` with NO posture set (default `single`) and capture the Installed packages row; then with a valid config and OS_TENANCY_POSTURE=isolated, confirm the run does NOT print the 'Unique scope ✓' success line while that entry is unreadable", @@ -1025,7 +1035,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os doctor` as a whole had NO checklist item; it appeared only as a --help variant on cli.flag-command-error-ux while carrying seven unit-pinned seams (env provenance #5387, posture honesty #5382, ledger independence #5429, withheld ✓ #5412/#5413/#5644). Authored from the run body at doctor.ts with the withhold-the-✓ discipline the command itself established turned back on its own monorepo-anchored Test-coverage/Deprecations ✓s, which bless a tree they never examined in every user app — encoded as an expected-fail probe rather than silently accepted", "ref": "claude/new-session-0pv25p" }, - { "revision": 2, "date": "2026-08-23", "change": "text refresh only, no capability change: #10679 landed the fix this item's expected-fail predicted, so that clause inverts into an ordinary pass. findMissingTests/findDeprecatedUsages now return a MonorepoTreeScan and the ✓ is reachable only from the scanned arm; outside a monorepo each check prints 'ℹ Skipped — no packages/spec/src in this directory (monorepo-only check)', with the resolved directory under -v. Rewritten against a REAL run of the shipped CLI in a `create-objectstack -t blank` scaffold and again from a monorepo checkout, not from source: (a) the expected-fail clause, step 7 and the item title inverted; (b) the row enumeration in acceptance[0] and step 1 corrected — measurement falsified the premise that only @objectstack/spec became conditional: `Installed packages` was ALREADY findings-only (installedPackageLedgerChecks returns [] on a clean ledger) and had never been an unconditional row, and NODE_ENV/Tenancy posture are finding-only too, so the enumeration now splits unconditional from finding-only rather than naming one exception; (c) the '@objectstack/spec Not built' negative flipped from an accepted note into a regression, its probe being gated; (d) two negatives added for the skip row's own wrong shapes (a ⚠ instead of a ℹ, and the row vanishing); (e) doctor-unexamined-spec-tree.test.ts added to the pins; (f) every line anchor in the item re-derived at this base — doctor.ts grew ~90 lines, so every number quoted at revision 1 had moved", "ref": "#10798" } + { "revision": 2, "date": "2026-08-23", "change": "text refresh only, no capability change: #10679 landed the fix this item's expected-fail predicted, so that clause inverts into an ordinary pass. findMissingTests/findDeprecatedUsages now return a MonorepoTreeScan and the ✓ is reachable only from the scanned arm; outside a monorepo each check prints 'ℹ Skipped — no packages/spec/src in this directory (monorepo-only check)', with the resolved directory under -v. Rewritten against a REAL run of the shipped CLI in a `create-objectstack -t blank` scaffold and again from a monorepo checkout, not from source: (a) the expected-fail clause, step 7 and the item title inverted; (b) the row enumeration in acceptance[0] and step 1 corrected — measurement falsified the premise that only @objectstack/spec became conditional: `Installed packages` was ALREADY findings-only (installedPackageLedgerChecks returns [] on a clean ledger) and had never been an unconditional row, and NODE_ENV/Tenancy posture are finding-only too, so the enumeration now splits unconditional from finding-only rather than naming one exception; (c) the '@objectstack/spec Not built' negative flipped from an accepted note into a regression, its probe being gated; (d) two negatives added for the skip row's own wrong shapes (a ⚠ instead of a ℹ, and the row vanishing); (e) doctor-unexamined-spec-tree.test.ts added to the pins; (f) every line anchor in the item re-derived at this base — doctor.ts grew ~90 lines, so every number quoted at revision 1 had moved", "ref": "#10798" }, + { "revision": 3, "date": "2026-10-17", "change": "steps[1] said how to stage the three config findings but not that the staged config must load (checklist-accuracy finding, #23878 close-out: 'orphan-view staging needs defineStack strict:false, the default load refuses it, and doctor then skips config analysis'). Re-measured on main c91af8ed6, the finding holds for a malformed view body only: a well-formed container bound to a missing object loads under the default defineStack and doctor reports it, so strict:false is an option, not a requirement. The step names the well-formed shape, the skip row a refused load prints, and the strict:false route; knownGaps[0] says the staged config must load. Re-run on a scratch config (circular lookup pair qa_a/qa_b, unreferenced qa_unused): with the container { name: 'qa_missing', object: 'qa_missing', list } under the default load, os doctor exit 0 with '⚠ Circular dependency: qa_a → qa_b → qa_a', '⚠ Object \"qa_unused\" is defined but not referenced …' and '⚠ View \"qa_missing\" references non-existent object \"qa_missing\"'; the same three under strict:false with a mismatched-name container; a container carrying viewKind/config → '⚠ Config load Could not load config for analysis (config checks skipped) — defineStack validation failed …' and none of the three rows; the restored config → '✓ Dependencies No circular references detected', '✓ Object usage All objects are referenced', '✓ View integrity All views reference valid objects', exit 0", "ref": "#24036" } ] }, { @@ -1124,7 +1135,7 @@ "title": "os migrate duplicates: a read-only JSON inventory of identifiers minted across partitions — within-partition repeats excluded, nothing written, the live two-counter condition reported, and runnable BEFORE the #8686 repair destroys the evidence", "since": "v17", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "cli", "personas": ["operator (local shell, pre-repair audit)"], @@ -1144,7 +1155,7 @@ "seed via direct SQL per the fixture recipe: the cross-partition duplicate, the within-partition repeat, an organizations row for '', and the paired sequence counters", "dump the DB's CONTENT before the run — every user table's rows (sqlite3 '.dump' with the CREATE lines aside, or one ordered SELECT * per table), `SELECT * FROM _objectstack_sequences ORDER BY 1`, and `SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY name` — and checksum each dump; run `os migrate duplicates > report.json; echo $?`; dump and checksum again and byte-compare the three pairs. ⛔ Do not use the whole-file md5 as the oracle: it is not what the read-only contract promises (rows, counters and schema)", "jq the report: .report/.reportVersion/.generatedAt/.database/.globalPartition/.filter/.counters/.scanned/.skipped/.duplicates/.liveConditions/.runtimeIndexPreflight/.summary", - "seed the kernel:ready blocker too (#8725): the first boot already TIGHTENED idx_sys_metadata_overlay_active, so first put back the declared NULL-distinct form an untightened install still has (DROP INDEX idx_sys_metadata_overlay_active, then CREATE UNIQUE INDEX idx_sys_metadata_overlay_active ON sys_metadata (type, name, organization_id, package_id)); then insert two ACTIVE sys_metadata rows with the SAME type and name, the SAME non-NULL organization_id and package_id NULL, every other NOT NULL column filled — then jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked", + "seed the kernel:ready blocker too (#8725). sys_metadata carries NO organization_id column: the runtime index is idx_sys_metadata_overlay_active ON sys_metadata (type, name, COALESCE(package_id, ''), COALESCE(bundle_member, '')) WHERE state = 'active', and the declared NULL-distinct fallback an untightened install has is UNIQUE (type, name, package_id, bundle_member) (sys-metadata.object.ts). Read the index's current SQL first; if it is the tightened form, put the declared one back (DROP INDEX idx_sys_metadata_overlay_active, then CREATE UNIQUE INDEX idx_sys_metadata_overlay_active ON sys_metadata (type, name, package_id, bundle_member)). Then insert two ACTIVE sys_metadata rows with the SAME type and name and package_id and bundle_member both NULL — legal under the NULL-distinct form, colliding under the COALESCE one — and jq .runtimeIndexPreflight and .summary.runtimeIndexesBlocked. Seed a metadata body the spec accepts for that type, or expect a [metadata_spec_invalid] hydration line naming the row on any later boot or plan (the boot reading it, not a report finding)", "run `os migrate duplicates --object ` and `--object ` — capture .filter in both payloads", "run `os migrate duplicates --database-url file:/tmp//dup.db` and confirm it reaches the same DB (the flag also honors OS_DATABASE_URL)", "negative (no_sql_seam): run `pnpm --filter @objectstack/cli exec vitest run src/commands/migrate/duplicates.null-seam.test.ts` and cite its pass — the seam double that answers no result set, driving the real command; then run `os migrate duplicates --database-url memory://qa; echo $?` and capture the boot_failed payload (the retired engine is refused at boot, naming the replacement)", @@ -1172,7 +1183,7 @@ { "clause": "the kernel:ready pre-flight (#8725) reports the RUNTIME-migration class os migrate plan cannot see: one entry per index the two kernel:ready migrations tighten (three — the overlay migration owns two; the third migration, sys_view_definition's, retired with its table under ADR-0131 D13), each blocked|clear|table-absent|unreadable, a blocked one naming every colliding key group and its row count", "oracle": "log", - "verify": ".runtimeIndexPreflight names idx_sys_metadata_overlay_active as blocked with the seeded type, name and organization_id and package_id_key '' — and the SAME database run through `os migrate plan` mentions neither the index nor the seeded name (the matched control: the declared-index duplicate above IS reported by plan, this one is not); no entry names sys_view_definition", + "verify": ".runtimeIndexPreflight names idx_sys_metadata_overlay_active as blocked with one key group carrying the seeded type and name, package_id_key '' and bundle_member_key '', and its row count — and the SAME database run through `os migrate plan` mentions neither the index nor the seeded name (the matched control: the declared-index duplicate above IS reported by plan, this one is not); no entry names sys_view_definition", "evidence": "the pre-flight section beside the plan output for one database" }, { @@ -1229,6 +1240,12 @@ "date": "2026-10-08", "change": "the kernel:ready blocker re-seeded on sys_metadata: sys_view_definition retired as inert with its active-row index migration (ADR-0131 D13), so the pre-flight now probes three indexes from two migrations and never names that table. The seeding step and A4 now use two ACTIVE package-less sys_metadata overlays for one type, name and NON-NULL organization — the organization key part stays bare in the tightened index (#6418), so a NULL-organization pair would not block its CREATE — and the step first puts back the declared NULL-distinct index the first boot replaced, without which the insert is refused. Source runtime-index-preflight.ts runtimeIndexProbes", "ref": "#15206" + }, + { + "revision": 7, + "date": "2026-10-17", + "change": "steps[4]'s index recipe is stale (checklist-accuracy finding, #23878 close-out): sys_metadata has no organization_id column, and idx_sys_metadata_overlay_active is keyed (type, name, package_id, bundle_member) — the runtime form folds both nullable parts through COALESCE under WHERE state = 'active'. The step now names both forms, says to read the index first, and seeds two ACTIVE rows with NULL package_id and bundle_member; clause 3's verify names the key group the report actually carries. Re-run on main c91af8ed6 on a scratch database from a minimal stack's boot (its overlay index was the declared NULL-distinct form): two ACTIVE view rows named qa_dup_view inserted; os migrate duplicates --database-url file:SCRATCH exit 0, .runtimeIndexPreflight idx_sys_metadata_overlay_active status blocked with one group {type view, name qa_dup_view, package_id_key '', bundle_member_key ''} rowCount 2, idx_sys_metadata_overlay_draft and the sys_setting identity index clear, no sys_view_definition entry, summary runtimeIndexesBlocked 1 and runtimeIndexBlockingRows 2; os migrate plan over the same file listed neither the index nor qa_dup_view among its changes (its only mention of the name is a hydration line, because the seeded '{}' body is not a valid view)", + "ref": "#24036" } ] }, @@ -1237,7 +1254,7 @@ "title": "os datasource list-tables/introspect/validate: the federation door answers, the generated draft is cwd-jailed and compiles, and schema drift maps to ✗ diffs with exit 1", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "cli", "personas": ["operator (local shell) whose token HOLDS manage_platform_settings — all three commands address federation READ routes, which require it since #9901/#10255", "integration author (adopting a remote table), same capability"], @@ -1259,7 +1276,7 @@ "`os datasource list-tables showcase_external --url http://localhost: --token ` — capture the table listing; repeat with `--schema ` and with a schema that matches nothing ('No remote tables found.'). On the stock SQLite fixture both runs list every table, since there is no schema to filter on: record that and score the narrowing from the pins", "`os datasource introspect showcase_external --table --url … --token ` — draft printed to stdout; then re-run with `--out objects/qa_external.object.ts` and capture 'Wrote objects/qa_external.object.ts' plus any 'REVIEW: column …' warnings", "escape probes: re-run with `--out ../qa-escape.object.ts` and `--out /tmp/qa-escape.object.ts` — capture both refusals and `echo $?`, and confirm neither file exists afterwards", - "wire the generated object into the scratch config and run `os build; echo $?`", + "wire the generated object into the scratch config and run `os build; echo $?`. The draft is named after the datasource's own prefix (introspecting showcase_external's customers table yields showcase_customers), and defineStack's namespace-prefix check refuses an object whose name lacks the package namespace — so give the scratch manifest namespace: 'showcase' (the draft's prefix), or rename the object to NAMESPACE_…, before reading the build verdict; a mismatched namespace scores the scratch wiring, not the generated draft", "`os datasource validate showcase_external --url … --token ; echo $?` on the untouched fixture (expect ✓ rows, exit 0); then rename a column in the fixture DB (sqlite3 ALTER TABLE) or edit the adopted object's column mapping, re-run, and capture the ✗ diff rows and exit 1", "negative: run list-tables and introspect against a datasource name that does not exist and capture each error + exit code. Also run `os datasource validate ; echo $?` and RECORD (do not score as an error-arm probe) its vacuous ok: the validate route is scoped by the call to the federated objects bound to :name, an unknown name binds none, so the server answers {ok:true, results:[]} and the CLI prints 'No federated objects to validate.' with exit 0 — by design" ], @@ -1334,15 +1351,21 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21721 parked on #22017 (row 24). A1's narrowing half was blocked(fixture): SQLite tables carry no schema to filter on. Verified: listRemoteTables skips the ?schema= filter for a table with no schema part, so no stock run can narrow. Disposition: pin. `automated` names the twin-equivalence route test, the service test and the CLI's empty-list test for that half, and a knownGap names the unpinned flag forwarding and the driver it needs. knownGaps[0] said no test referenced the three commands; envelope-unwrap.test.ts has since #10675, so it is corrected", "ref": "#22017" + }, + { + "revision": 5, + "date": "2026-10-17", + "change": "steps[4] left out what wiring the draft needs (checklist-accuracy finding, #23878 close-out): the generated object carries the datasource's prefix, and a scratch manifest with any other namespace refuses it at defineStack. The step now says to match the namespace or rename. Re-run on main c91af8ed6 against the running showcase boot (admin token): list-tables showcase_external → customers (7 cols), orders (7 cols); introspect --table customers --out objects/qa_external.object.ts → 'Wrote objects/qa_external.object.ts', export showcase_customers bound to datasource showcase_external; wired into a scratch config with namespace 'qa' → os build exit 2 \"Object 'showcase_customers' is missing the package namespace prefix. Rename it to 'qa_showcase_customers' (namespace = 'qa').\"; with namespace 'showcase' → exit 0, 1 object, 7 fields", + "ref": "#24036" } ] }, { "id": "cli.hook-body-extraction-gates", - "title": "Hook/action body extraction: --strict-body refuses every forbidden pattern with its own worded reason, capability tokens are inferred (and crypto.hash is NOT, #4391), free identifiers fall back to bundling — and the DEFAULT build's silent fallback contradicts the extractor's own contract (expected-fail)", + "title": "Hook/action body extraction: --strict-body refuses every forbidden pattern with its own worded reason, capability tokens are inferred (and crypto.hash is NOT, #4391), free identifiers fall back to bundling — and the DEFAULT build's warn-and-bundle fallback SURFACES every refusal (#10678)", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "cli", "personas": ["build-time author (no session)"], @@ -1354,12 +1377,12 @@ ], "knownGaps": [ "the RUNTIME half of every token (the sandbox rejecting an undeclared call, log routing) is records-forms territory (its L2 sandbox item cites body-runner.ts) — this item owns the BUILD-side gate and inference only; do not re-prove the sandbox here", - "bodyExtractionWarnings are invisible on a default build (see the expected-fail clause), so the default-path evidence must come from the artifact (body absent, bundle emitted), not from output that will not appear" + "[CLOSED by #10678, re-measured under #24036] bodyExtractionWarnings used to be invisible on a default build; the default build now prints a 'N handlers could not be lowered to a metadata body — bundled via the legacy runtime module instead (build still succeeds)' block with one line per refused handler, and --json carries the same records as bodyExtractionWarnings. The artifact (body absent, bundle emitted) stays the corroborating oracle" ] }, "steps": [ "author the forbidden seven, one hook each: dynamic import(, require(, fetch(, process., globalThis., eval(, new Function( — then run `os build --strict-body; echo $?` and capture every per-callable diagnostic verbatim", - "the default-path pair on the SAME config: `os build; echo $?` — capture the exit, the 'Bundling N handler(s)…' line, and jq the artifact for each hook's body (expect ABSENT) and handler ref (expect present)", + "the default-path pair on the SAME config: `os build; echo $?` — capture the exit, the 'Bundling N handler(s)…' line, and jq the artifact for each hook's body (expect ABSENT) and handler ref (expect present); capture the 'N handlers could not be lowered to a metadata body' warning block with its per-hook lines, and run `os build --json` on the same config to capture its bodyExtractionWarnings array", "the inference matrix, one hook each on a clean config: ctx.api.object('x').find(…) · ctx.api.object('x').update(…) · const api = ctx.api; api.object('x').find(…) · ctx.crypto.randomUUID() · ctx.log.info(…) · a body whose ONLY crypto call is ctx.crypto.hash(…) · a body carrying the retired @capabilities first-line comment and no matching calls (#10917 — it must contribute nothing) · a hook supplying body.capabilities as data instead of a handler · an implicit-return arrow — `os build` then jq each hook's body.capabilities and isExpression handling", "free-identifier probe: a handler calling a module-scope helper function — `os build; echo $?` (expect green, handler bundled, no body) then `os build --strict-body; echo $?` (expect exit 1 naming the identifier)", "all-body-only run: remove every non-extractable handler and confirm the 'Skipping legacy runtime bundle (all N callables are body-only)' line and that no objectstack-runtime.*.mjs remains in dist/", @@ -1403,10 +1426,10 @@ "evidence": "the line + the dist listing" }, { - "clause": "EXPECTED-FAIL (contract contradiction — record actual behavior): on a DEFAULT `os build`, a forbidden-pattern hook ships SILENTLY via the.mjs bundle — the extraction failure is caught (lower-callables.ts), the warning is recorded in bodyExtractionWarnings but printed NOWHERE on the default path and excluded from the --json success payload (whose warnings key carries rule advisories only, compile.ts) — while the extractor's own contract header promises the build FAILS with 'no silent fallback to the L3.mjs path because that path is being closed' (extract-hook-body.ts). hook-bodies.mdx documents the warn-and-bundle default, so the DOCS and the CODE agree with each other and both contradict the extractor's stated contract. The clause: a forbidden pattern on the default path must at least SURFACE its warning; today the only observable trace is the bundle's existence", + "clause": "the DEFAULT `os build` warn-and-bundle path is NOT silent (#10678): a forbidden-pattern hook still ships through the .mjs bundle and the build exits 0 — lower-callables.ts catches the extraction failure, and making the default fatal would change what os build accepts — but compile.ts prints 'N handler(s) could not be lowered to a metadata body — bundled via the legacy runtime module instead (build still succeeds)' with one line per refused handler naming that handler's FORBIDDEN_PATTERNS reason, and the --json success payload carries the same records under bodyExtractionWarnings (a key of its own, apart from the rule-advisory warnings). The extractor's contract header now documents this default. A default run that is silent again, or a refused handler missing from either list, FAILS the clause", "oracle": "build", - "verify": "the default run over the forbidden seven exits 0 with zero extraction-related output — grep the transcript and the --json payload for any of the seven reasons (expect none) while jq confirms all seven hooks are body-less and the bundle was emitted; the clause FAILS on that silence and flips when the warnings surface (or the header's fail-the-build contract is actually enforced)", - "evidence": "the silent transcript + --json payload + the jq body-absence sweep" + "verify": "the default run over the forbidden seven exits 0 and prints the warning block naming each of the seven hooks with its own reason; the --json run exits 0 with success true and bodyExtractionWarnings holding one {origin, reason} record per refused hook; jq confirms all seven hooks are body-less and the runtime bundle was emitted (compile.ts, the #10678 surfacing; lower-callables.ts, the catch-and-bundle)", + "evidence": "the warning block + the --json bodyExtractionWarnings + the jq body-absence sweep" } ], "negative": [ @@ -1419,15 +1442,16 @@ "traps": ["stale-dist", "absence-inference"], "source": [ "packages/cli/src/utils/extract-hook-body.ts#FORBIDDEN_PATTERNS (contract header, carrying the #10917 retirement note; FORBIDDEN_PATTERNS; CAPABILITY_PATTERNS with the #4391 removal note; #1876 free-identifier throw — the @capabilities override branch this item used to cite is retired and gone)", - "packages/cli/src/utils/lower-callables.ts#tryExtractBody (the warnings field, tryExtractBody's catch-all fallback-to-bundle — the seam the default path's silence flows from)", - "packages/cli/src/commands/compile.ts#needsBundle (the --strict-body gate, the needsBundle decision + skip line, the --json warnings key that excludes extraction warnings)", + "packages/cli/src/utils/lower-callables.ts#tryExtractBody (the warnings field, tryExtractBody's catch-all fallback-to-bundle — the seam whose records the default path now prints)", + "packages/cli/src/commands/compile.ts#needsBundle (the --strict-body gate, the needsBundle decision + skip line, the #10678 default-path warning block and the --json bodyExtractionWarnings key)", "content/docs/automation/hook-bodies.mdx (the documented default + --strict-body posture; the Capability inference section — the inference table, the body.capabilities route, and the callout recording the retired directive)", "packages/cli/src/utils/lower-callables.test.ts (the existing unit seam pin — cited, not a substitute for driving os build)", "sibling items records-forms (runtime sandbox side, body-runner.ts) and cli.build-own-contract (the build's own exit/output contract)" ], "history": [ { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts) produces the worded refusals; hook-bodies.mdx documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" }, - { "revision": 2, "date": "2026-08-22", "change": "the '// @capabilities' first-line override was RETIRED (#10917, maintainer ruling under ADR-0049 enforce-or-remove): measured on all four ordinary authoring shapes (.ts/.js/.mjs/an imported handler) it reached the extractor from NONE of them, so a documented directive silently taught a wrong convention and the mismatch surfaced far from its cause as a sandbox refusal. Its acceptance clause asserted behaviour that no longer exists — a runner would have filed a FAIL against the ruling — and is replaced by the covered route (body.capabilities as data) plus the inertness guarantee owed to configs that still carry the comment. Steps, fixtures and the extract-hook-body.ts line citations moved with it. No other clause changed; note the expected-fail clause still describes the pre-#10678 silent default and is stale on its own account, which is NOT this revision's subject", "ref": "claude/issue-10917-retire-capabilities-hook-directive" } + { "revision": 2, "date": "2026-08-22", "change": "the '// @capabilities' first-line override was RETIRED (#10917, maintainer ruling under ADR-0049 enforce-or-remove): measured on all four ordinary authoring shapes (.ts/.js/.mjs/an imported handler) it reached the extractor from NONE of them, so a documented directive silently taught a wrong convention and the mismatch surfaced far from its cause as a sandbox refusal. Its acceptance clause asserted behaviour that no longer exists — a runner would have filed a FAIL against the ruling — and is replaced by the covered route (body.capabilities as data) plus the inertness guarantee owed to configs that still carry the comment. Steps, fixtures and the extract-hook-body.ts line citations moved with it. No other clause changed; note the expected-fail clause still describes the pre-#10678 silent default and is stale on its own account, which is NOT this revision's subject", "ref": "claude/issue-10917-retire-capabilities-hook-directive" }, + { "revision": 3, "date": "2026-10-17", "change": "clause 6's EXPECTED-FAIL has flipped (checklist-accuracy finding, #23879 and the #23878 close-out; the r2 history already flagged it stale): since #10678 the default build prints the refused handlers and --json carries them as bodyExtractionWarnings. The title, clause 6 (now a positive assertion that fails if the default path goes silent again), its verify and evidence, steps[1], knownGaps[1] and two source parentheticals now describe the surfaced default. Re-run on main c91af8ed6 on a scratch stack with the forbidden seven, a module-scope helper and a clean find hook: default os build exit 0 with '⚠ 8 handlers could not be lowered to a metadata body — bundled via the legacy runtime module instead (build still succeeds)' and one bullet per hook carrying its own reason (import and fetch prescribe a Connector recipe, require, process, globalThis, eval and new Function name themselves, the helper hook names 'helper'); --json exit 0, success true, bodyExtractionWarnings 8 records; --strict-body exit 1 '--strict-body: 8 callable(s) lack a metadata body'; the artifact carries no body on the eight, body.capabilities ['api.read'] on the find hook, and one objectstack-runtime bundle in dist", "ref": "#24036" } ] }, { @@ -1635,14 +1659,14 @@ "title": "autoMigrate off|safe: a dev boot self-heals ONLY the safe-categorized drift and reports each applied op, `off` (the driver default) and NODE_ENV=production never touch the schema, and a duplicates-blocked tighten is never auto-applied", "since": "v15", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], "fixtures": { "app": "showcase", "requires": [ - "a SCRATCH app copy (worktree copy of examples/app-showcase or a scaffolded blank app) whose config declares a `default` sqlite datasource with a file DB under /tmp// — deliberate metadata edits stage the drift, so shared fixtures are never touched", + "a SCRATCH app copy (a scaffolded blank app or a minimal stack beside a symlinked node_modules) whose config declares a NAMED sqlite datasource (e.g. qa_main — defineStack refuses a datasource named `default`: 'that name is reserved for the host's primary datasource, and every boot refuses a stack that declares it') with a file DB under the run's scratch directory, routed as the primary by datasourceMapping: [{ default: true, datasource: 'qa_main' }]; deliberate metadata edits and direct writes to the STOPPED scratch file stage the drift, so shared fixtures are never touched", "a debug-readable boot log for every boot — the safe-apply evidence is the driver's own [schema-drift] lines", "for the config-declared arms: the datasource's driver `config` is where `autoMigrate` is authored (sqlite.zod.ts; the same key on postgres/mysql); for the injected arm: a boot with NO config-declared datasource, whose fallback default datasource gets `autoMigrate: 'safe'` from the CLI only when isDev" ], @@ -1653,7 +1677,7 @@ }, "variants": [ "off — the driver default when the key is omitted (sql-driver.ts `autoMigrate ?? 'off'`): drift is WARNED, never applied", - "safe — the loosen-only subset applied at boot (relax NOT NULL, widen varchar, safe index ops, clean-probe tighten), never destructive DDL", + "safe — the safe-category subset applied at boot (the clean-probe replacement of a legacy platform-wide unique index, a missing declared index, dropping a runtime-token column default, widening a varchar on dialects that enforce length — not sqlite), never needs_confirm or destructive DDL: relaxing a NOT NULL column is needs_confirm and is never auto-applied", "dev injection — `os dev`/`os serve --dev` pass 'safe' themselves for the FALLBACK default datasource (storage-driver.ts) and the telemetry datasource (serve.ts); non-dev serve passes nothing, so the driver default 'off' stands", "NODE_ENV=production — 'safe' force-ignored with the named warning, schema never auto-altered", "schemaMode gate — safe is honoured only under schemaMode 'managed' (sql-driver.ts autoOn = safe && managed); an external-schema datasource is never auto-altered", @@ -1661,25 +1685,25 @@ ], "enumSource": { "file": "packages/spec/src/data/driver/common.zod.ts", "export": "SqlAutoMigrateSchema", "expect": 2 }, "steps": [ - "stage real drift in the scratch copy: declare a field `required: true`, `os build`, boot once against the file DB so the NOT NULL column lands; stop; flip the field to `required: false`, `os build` — the physical column is now STRICTER than metadata, which is exactly the loosening drift 'safe' exists for", - "the off side first: boot with the datasource's config carrying `autoMigrate: 'off'` (and once more with the key OMITTED — the driver default); capture the [schema-drift] WARNING for the divergence in both boots, then stop and run `os migrate plan` — the drift must still be pending, and a schema dump before/after the two boots must be identical", - "the safe side: boot the same DB with `autoMigrate: 'safe'`; capture the `[schema-drift] auto-reconciled on .` info line(s); stop and run `os migrate plan` — the loosened column must no longer be pending; boot AGAIN and confirm no further reconcile lines (the heal converged)", - "production force-off: re-stage the drift, then boot with NODE_ENV=production and `autoMigrate: 'safe'` still declared; capture the exact warning `[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production — schema is never auto-altered in production. Run 'os migrate' deliberately.` and confirm via schema dump that nothing was altered", - "the injected arm: in a scratch project with NO config-declared datasource, re-stage the drift and boot plain `os dev` — the CLI's fallback default datasource carries autoMigrate:'safe' in dev (storage-driver.ts), so the same auto-reconciled line must appear with no authored key anywhere", - "the destructive boundary on the same boots: stage a REMOVED field too (a drop is category destructive) and confirm the safe boot leaves it as a warning + pending plan entry — `os migrate apply --allow-destructive --yes` remains the only door (cross-ref cli.migrate-plan-apply-json)", + "stage three drifts in the scratch copy, one per category: (1) needs_confirm — declare a field with `storage: { notNull: true }` (`required: true` no longer makes a NOT NULL column, ADR-0113), build, boot once against the file DB so the NOT NULL column lands, stop, drop the storage constraint and build: relax_not_null is needs_confirm, so it is the warn-only specimen on every boot; (2) safe — give a field `unique: true` (the boot lands the tenant-scoped uniq_TABLE_organization_id_COLUMN index), then with the server STOPPED plant the legacy platform-wide index on the scratch file: CREATE UNIQUE INDEX TABLE_COLUMN_unique ON TABLE(COLUMN) — the replace_unique_index drift, safe after a clean duplicate probe; (3) destructive — remove a field from metadata. Dump the scratch file's schema (sqlite_master for the table) before every boot below", + "the off side first: boot with the datasource's config carrying `autoMigrate: 'off'` (and once more with the key OMITTED — the driver default); capture the [schema-drift] WARNING for each of the three divergences in both boots and confirm the schema dump before/after the two boots is identical. ⚠ `os migrate plan` is not the pending oracle here: with the primary on a config-declared datasource it composes no driver for it and prints a PARTIAL plan ('Examined 0 managed table(s)', 'N of N declared object(s) are NOT … bound to no driver'), so the next boot's warning plus the dump stands in for it", + "the safe side: boot the same DB with `autoMigrate: 'safe'`; capture the `[schema-drift] auto-reconciled replace_unique_index on TABLE.COLUMN` info line and confirm the dump lost the legacy index, while the relax_not_null and orphaned-column warnings still print; boot AGAIN and confirm no further reconcile lines (the heal converged)", + "production force-off: re-plant the legacy index, then boot with NODE_ENV=production and `autoMigrate: 'safe'` still declared; capture the exact warning `[schema-drift] autoMigrate='safe' is ignored under NODE_ENV=production — schema is never auto-altered in production. Run 'os migrate' deliberately.` and confirm via schema dump that nothing was altered", + "the injected arm: in a scratch project with NO config-declared datasource (os dev -d file:HOST_DB), boot once, stop, plant the same legacy index in that file and boot plain `os dev` — the CLI's fallback default datasource carries autoMigrate:'safe' in dev (storage-driver.ts), so the same auto-reconciled line must appear with no authored key anywhere; then re-plant it and boot non-dev `os start` over the same file — warnings only, the index stays", + "the destructive and needs_confirm boundary on the same boots: the removed field (orphaned column, destructive) and the relaxed NOT NULL (needs_confirm) stay warnings on every safe boot — `os migrate apply` (with --allow-destructive for the drop) remains the only door (cross-ref cli.migrate-plan-apply-json)", "the duplicates-blocked leg: run the unit pin (packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts + sql-driver-schema-drift.test.ts) and cite its output — or hand-stage duplicate rows in the scratch sqlite file and boot with safe, capturing the blocked report; record which route the verdict rests on" ], "acceptance": [ { "clause": "`safe` applies EXACTLY the safe-categorized diffs at boot and reports each one: every applied op logs `[schema-drift] auto-reconciled on
.` (info), drift is re-detected after the reconcile, and anything remaining is still warned once per divergence — never silently swallowed", "oracle": "log", - "verify": "the safe boot's log carries one auto-reconciled line per staged loosening; a follow-up `os migrate plan` no longer lists it; a second safe boot reconciles nothing (converged). Mechanism: reconcileAndWarnDrift filters category === 'safe' into applyMigrationEntries({ allowDestructive: false }) (sql-driver.ts)", - "evidence": "the boot log lines + the before/after plan outputs" + "verify": "the safe boot's log carries one auto-reconciled line per staged safe-category drift and none for the needs_confirm or destructive ones; the dump lost the legacy index; a second safe boot reconciles nothing (converged). Mechanism: reconcileAndWarnDrift filters category === 'safe' into applyMigrationEntries({ allowDestructive: false }) (sql-driver.ts)", + "evidence": "the boot log lines + the before/after schema dumps" }, { "clause": "`off` — explicit OR omitted — boots WITHOUT touching the schema: the same drift is warned (`[schema-drift] `) on every boot, two consecutive off boots report identical drift, and the physical schema is byte-identical before and after", "oracle": "log", - "verify": "paired boot logs show the warning both times with no auto-reconciled line; the schema dumps (or a third `os migrate plan`) match exactly. The default is 'off' at the DRIVER (sql-driver.ts) — only the CLI's dev boots inject 'safe'", + "verify": "paired boot logs show the warnings both times with no auto-reconciled line; the schema dumps match exactly. The default is 'off' at the DRIVER (sql-driver.ts) — only the CLI's dev boots inject 'safe'", "evidence": "the two boot logs + the schema-dump pair" }, { @@ -1691,8 +1715,8 @@ { "clause": "the gate composes: safe runs only under schemaMode 'managed' (autoOn = autoMigrate==='safe' && schemaMode==='managed'), and the DESTRUCTIVE category is never auto-applied under any setting — a staged drop stays a warning + pending plan entry on the safe boot, applied only through `os migrate apply --allow-destructive`", "oracle": "log", - "verify": "the safe boot's log shows the drop warned, not applied; `os migrate plan` still lists it; the applyMigrationEntries call is pinned allowDestructive:false (sql-driver.ts)", - "evidence": "the safe-boot log + the still-pending plan" + "verify": "the safe boot's log shows the drop (and the needs_confirm relax) warned, not applied, and the next boot warns them again; the applyMigrationEntries call is pinned allowDestructive:false (sql-driver.ts)", + "evidence": "the safe-boot logs + the dump still carrying the orphaned column" }, { "clause": "the ADR-0120 D4 tighten is data-dependent and fails CLOSED: the bare-organization-column → NULL-safe unique recreate goes through the duplicate pre-flight probe — clean probe ⇒ recategorised safe and auto-applied at boot; duplicates found ⇒ BLOCKED with a row report and the old index left in place, never auto-applied under either autoMigrate value", @@ -1724,7 +1748,8 @@ "sibling items: cli.dev-boot-contract (the boot this rides), cli.migrate-plan-apply-json (the deliberate apply path — the ONLY door for destructive diffs; cross-referenced, not re-proven)" ], "history": [ - { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, angle 2): the autoMigrate off|safe policy had no coverage although it is the one path that mutates a dev database at boot. Grounded verify-first, correcting the register's sketch on two points: (1) 'safe' is the LOOSEN-only subset (relax NOT NULL, widen varchar, index ops, clean-probe tighten) — not 'additive' diffs; (2) the register's sql-driver.ts/4652-4654 cites the config/field declarations, but the enforcement lives in reconcileAndWarnDrift with the managed-mode gate, the production force-off warning, and allowDestructive:false — all now pinned as clauses. The dev-injection arm (storage-driver.ts / serve.ts, 'safe' only when isDev) was found while grounding and added as its own clause so a keyless dev boot's self-heal is not misread as a driver default. enumSource pins SqlAutoMigrateSchema expect 2", "ref": "#sweep-2026-08-30" } + { "revision": 1, "date": "2026-08-30", "change": "new item (sweep 2026-08-30, angle 2): the autoMigrate off|safe policy had no coverage although it is the one path that mutates a dev database at boot. Grounded verify-first, correcting the register's sketch on two points: (1) 'safe' is the LOOSEN-only subset (relax NOT NULL, widen varchar, index ops, clean-probe tighten) — not 'additive' diffs; (2) the register's sql-driver.ts/4652-4654 cites the config/field declarations, but the enforcement lives in reconcileAndWarnDrift with the managed-mode gate, the production force-off warning, and allowDestructive:false — all now pinned as clauses. The dev-injection arm (storage-driver.ts / serve.ts, 'safe' only when isDev) was found while grounding and added as its own clause so a keyless dev boot's self-heal is not misread as a driver default. enumSource pins SqlAutoMigrateSchema expect 2", "ref": "#sweep-2026-08-30" }, + { "revision": 2, "date": "2026-10-17", "change": "fixture and steps re-pointed (checklist-accuracy finding, #23878 close-out): a config-declared datasource named `default` is refused by defineStack, `required: true` no longer makes a NOT NULL column (storage.notNull, ADR-0113), and relax_not_null is needs_confirm, so the step-0 drift the safe leg relied on was never auto-appliable. The fixture now names a datasource routed by datasourceMapping default:true; step 0 stages one drift per category (storage.notNull relax = needs_confirm, a planted legacy platform-wide unique index = safe, a removed field = destructive); the steps read a schema dump instead of `os migrate plan`, which composes no driver for a config-declared primary and says its plan is PARTIAL; the safe variant lists the real safe ops; clauses 0, 1 and 3's verify follow. No clause's ruling changed. Re-run on main c91af8ed6 on a minimal scratch stack: datasource named default → build exit 2 with the reserved-name refusal; qa_main + mapping built. off and omitted boots each warned all three drifts and left the dump byte-identical; `os migrate plan` (also with --database-url) reported 'Examined 0 managed table(s)' and a PARTIAL plan. safe boot: '[schema-drift] auto-reconciled replace_unique_index on qa_thing.code', the legacy qa_thing_code_unique gone from the dump, the relax and orphan warnings still printed; the second safe boot printed no reconcile line. NODE_ENV=production with safe: the named ignore warning verbatim, all three warnings, dump unchanged. Keyless os dev over a planted legacy index: auto-reconciled replace_unique_index; non-dev os start over a re-planted one: the warning only, the index kept. Duplicates leg on the pin: driver-sql sql-driver-unique-tenancy.test.ts (incl. 'auto-tightens at boot under autoMigrate: safe when the probe is clean' and 'BLOCKS the tightening on duplicate NULL-organization rows') + sql-driver-schema-drift.test.ts, 38 passed", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index b0da0d1a9b6..b04e5ad6610 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -309,7 +309,7 @@ "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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["seeded admin (admin@objectos.ai)"], @@ -322,7 +322,7 @@ "a readable boot log for BOTH boots (debug level) — the OFF-side oracle is the loaded-plugin roster, so the run needs the plugin count / `requires` list from each boot, not just the ON-side output" ], "knownGaps": [ - "the plugin's own inert line ('OS_SEARCH_PINYIN_ENABLED is off — inert') is UNOBTAINABLE in a CLI-served deployment and must not be used as the OFF-side oracle: the CLI gates one layer upstream and omits `pinyin-search` from `requires` when the flag is off, so the plugin is never loaded and never reaches its own inert branch. Run #7629 measured 47 loaded plugins with the flag off vs 48 with it on, and found ZERO occurrences of 'pinyin' in a 1.2 MB debug boot log. The clause below therefore reads the roster difference; the inert line is kept only as the alternative oracle for a host that loads the plugin unconditionally" + "the plugin's own inert line ('OS_SEARCH_PINYIN_ENABLED is off — inert') is UNOBTAINABLE in a CLI-served deployment and must not be used as the OFF-side oracle: the CLI gates one layer upstream and omits `pinyin-search` from `requires` when the flag is off, so the plugin is never loaded and never reaches its own inert branch. Run #7629 measured 47 loaded plugins with the flag off vs 48 with it on (51 vs 52 on main c91af8ed6 under #24036), and found ZERO occurrences of 'pinyin' in a 1.2 MB debug boot log. The clause below therefore reads the roster difference; the inert line is kept only as the alternative oracle for a host that loads the plugin unconditionally" ] }, "steps": [ @@ -348,7 +348,7 @@ "evidence": "the contrast query" }, { - "clause": "flag OFF (explicit false, restart): the INITIALS forms ('zw' for 张伟, 'hnkj'/'wf' for the account) go to zero — the companion is gone from the schema view and the filter never ORs it — while '张' still matches via the source column. ⛔ Do NOT expect full-pinyin 'zhangwei' to go to zero on the stock fixture: showcase_contact.email is searchable:true and the seeded row carries email zhangwei@huaning.example, so that term still matches through the SOURCE column with pinyin off (likewise 'huaning' on the account via website / billing_email). The initials are the real discriminator because no source column spells them", + "clause": "flag OFF (explicit false, restart): the INITIALS forms ('zw' for 张伟 and 'wf' for 王芳 on showcase_contact, 'hnkj' for 华宁科技 on showcase_account) go to zero — the companion is gone from the schema view and the filter never ORs it — while '张' still matches via the source column. ⛔ Do NOT expect full-pinyin 'zhangwei' to go to zero on the stock fixture: showcase_contact.email is searchable:true and the seeded row carries email zhangwei@huaning.example, so that term still matches through the SOURCE column with pinyin off (likewise 'wangfang' for 王芳 through her email, and 'huaning' on the account via website / billing_email). The initials are the real discriminator because no source column spells them", "oracle": "api", "verify": "the same three queries after the flagged restart split exactly that way, with no errors", "evidence": "post-restart response bodies" @@ -356,7 +356,7 @@ { "clause": "OFF is inert end-to-end, not half-disabled — and under CLI serving the gate sits one layer UPSTREAM of the plugin: with the flag off the CLI does not add `pinyin-search` to `requires`, so the plugin is never loaded (no hooks, no backfill, pinyin-pro never imported, and its own 'is off — inert' line never printed). A host that loads the plugin unconditionally takes the in-plugin inert branch instead. Either route satisfies the ADR-0049 no-half-state design: column provisioning and population share the SINGLE decision point resolveSearchPinyinEnabled", "oracle": "log", - "verify": "diff the two boots' loaded-plugin roster — the OFF boot is one plugin short and `pinyin-search` is absent from `requires` (#7629 measured 47 vs 48) — and confirm a full-text grep of the OFF debug boot log yields ZERO 'pinyin' occurrences. Do NOT wait for the plugin's inert line: it cannot appear when the plugin was never loaded, and treating its absence as a failure is the trap this clause was rewritten to remove. If the roster shows the plugin WAS loaded, then the inert line is required instead. No backfill activity on either route", + "verify": "diff the two boots' loaded-plugin roster — the OFF boot is exactly one plugin short and `pinyin-search` is absent from `requires` (#7629 measured 47 vs 48; main c91af8ed6 measured 51 vs 52 — the absolute count moves with the plugin set, the one-plugin difference with PinyinSearchPlugin as the missing entry is the oracle) — and confirm a full-text grep of the OFF debug boot log yields ZERO 'pinyin' occurrences. Do NOT wait for the plugin's inert line: it cannot appear when the plugin was never loaded, and treating its absence as a failure is the trap this clause was rewritten to remove. If the roster shows the plugin WAS loaded, then the inert line is required instead. No backfill activity on either route", "evidence": "both boots' plugin count / `requires` lines + the zero-hit grep over the OFF boot log (or, plugin-loaded hosts only, the inert line)" }, { @@ -390,7 +390,8 @@ "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" }, { "revision": 2, "date": "2026-08-11", "change": "the OFF-inertness clause now names an oracle that exists. It demanded a specific plugin log line ('OS_SEARCH_PINYIN_ENABLED is off — inert') which run #7629 proved unobtainable under CLI serving: the flag gates one layer upstream, the CLI omits pinyin-search from `requires`, the plugin is never loaded, and a 1.2 MB debug boot log contained zero occurrences of 'pinyin' (47 loaded plugins off vs 48 on). That is a STRONGER proof of inertness than the log line would have been, so the clause now reads the plugin roster / `requires` difference plus the zero-hit grep, and keeps the inert line only as the alternative oracle for a host that loads the plugin unconditionally — the run is told which route it is on by the roster itself. Step 7, the fixtures requirement and the plugin source citation follow; knownGaps records the unobtainability so the next author does not restore the log-line oracle. This was the item's ONLY partial in #7629 — the substance (both flag sides, per-variant recall, backfill) is unchanged", "ref": "#7647" }, - { "revision": 3, "date": "2026-08-18", "change": "re-targeted clause 3's OFF-side expectation at the initials forms. It demanded that 'zhangwei' stop returning 张伟 with the flag off, but the seeded contact carries email zhangwei@huaning.example and email is searchable:true, so the term still matches through the source column — correct behaviour that the clause scores as the flag failing to disable. The initials (zw / hnkj / wf) have no source-column spelling and are the honest discriminator (#9467 CF-4)", "ref": "#9386" } + { "revision": 3, "date": "2026-08-18", "change": "re-targeted clause 3's OFF-side expectation at the initials forms. It demanded that 'zhangwei' stop returning 张伟 with the flag off, but the seeded contact carries email zhangwei@huaning.example and email is searchable:true, so the term still matches through the source column — correct behaviour that the clause scores as the flag failing to disable. The initials (zw / hnkj / wf) have no source-column spelling and are the honest discriminator (#9467 CF-4)", "ref": "#9386" }, + { "revision": 4, "date": "2026-10-17", "change": "clause 2 named 'wf' as an account initials form, but 'wf' is the initials of the seeded CONTACT 王芳 (company 华宁科技); the clause now places 'wf' on showcase_contact and 'hnkj' on showcase_account, and its ⛔ note adds 'wangfang', which still matches 王芳 through her email with the flag off. The roster counts in clause 3 and the knownGap gain the current reading (51 vs 52) and clause 3 states that the one-plugin difference, not the absolute count, is the oracle. Re-run on main c91af8ed6: flag ON (stock, zh-CN default) contact zhangwei/zw/张 → 张伟, contact wf → 王芳, account huaning/hnkj/华 → 华宁科技; flag OFF (OS_SEARCH_PINYIN_ENABLED=false, fresh DB, debug log) contact zw → 0, wf → 0, 张 → 张伟, zhangwei → 张伟 and wangfang → 王芳 (email column), account hnkj → 0, huaning and 华 → 华宁科技; banner 'Plugins: 51 loaded' OFF vs 52 ON, the difference PinyinSearchPlugin, and zero 'pinyin' occurrences in the 589 KB OFF debug boot log", "ref": "#24036" } ] }, { @@ -682,7 +683,7 @@ "title": "Global search (GET /api/v1/search) skips what the caller cannot read instead of failing: an unreadable object is never queried, named or counted; a readable object is searched only on fields the caller may query; row scope still narrows every searched object", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -701,7 +702,7 @@ "why": "supplies the one persona every clause rests on: a non-admin whose read set excludes some objects (acceptance[0]-[2]), narrows showcase_invoice to its own rows (acceptance[3]) and excludes some sys_user search columns (acceptance[4]). Run as a seeded demo persona instead, the clauses fail the same way the recipe's own notes record: one is refused at the object gate and the other reads everything" }, "knownGaps": [ - "#21880 (open): a field-narrowed search is not fully governed by the narrowing when the optional pinyin search companion is on (OS_SEARCH_PINYIN_ENABLED). Run acceptance[4] with the flag OFF (the stock default). A hit seen with the flag ON is recorded against #21880, not filed as a new finding", + "#21880 (open): a field-narrowed search is not fully governed by the narrowing when the optional pinyin search companion is on (OS_SEARCH_PINYIN_ENABLED). Run acceptance[4] with the flag OFF, set EXPLICITLY: boot with OS_SEARCH_PINYIN_ENABLED=false. On the showcase the flag is NOT off by default — unset, it derives ON from the zh-CN entry in i18n.supportedLocales (packages/types/src/env.ts resolveSearchPinyinEnabled; the stock boot loads pinyin-search and backfills the companion). A hit seen with the flag ON is recorded against #21880, not filed as a new finding", "the two #21880 cases (acceptance[3] row scope on a searched object, acceptance[4] a term only in a hidden field) have no end-to-end pin yet; #21879's dogfood pins object-level skipping only, and the hidden-field narrowing is unit-pinned. A run of this item is their only end-to-end reading until #21880 adds them", "if no stock field satisfies acceptance[4]'s premise (the hidden column holds no value unique to it on this boot), record acceptance[4] blocked(fixture) and score the mechanism from the unit pin. Do not edit sys_user directly to plant one" ] @@ -777,6 +778,12 @@ "date": "2026-10-06", "change": "new: PR #21879 made global search skip the objects and fields a caller cannot read instead of failing the whole request with 403, and no item asserted it. search.console-global-search drives the same route but only for row-level parity. This item adds the object-level skip (never queried, named or counted; an explicit objects= naming the object answers like no such object; the object still refused at its own door), plus the two cases #21880 lists: row scope still narrows a searched object, and a term only in a field hidden from the caller yields no hit. #21880's open pinyin-companion finding is recorded as a knownGap with the flag-off instruction. The persona reuses the area recipe qa-contributor-bound-member", "ref": "#21932" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "knownGaps[0] called pinyin OFF 'the stock default' (#23878 checklist-accuracy finding). On the showcase, OS_SEARCH_PINYIN_ENABLED unset derives ON from the zh-CN locale (resolveSearchPinyinEnabled; re-measured on main c91af8ed6: the stock boot loads com.objectstack.plugin.pinyin-search and logs the companion backfill), so the run must set the flag false explicitly for acceptance[4]. Wording only, no re-run owed", + "ref": "#24036" } ] }