From 38ebb9fe3ffef1a341437c956d1b8047d4a87c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:45:03 +0000 Subject: [PATCH 1/3] docs(qa): re-point the attachments and search checklist anchors at what the files declare now attachments-storage.attach-requires-parent-edit (revision 6): the source anchor names mayEditParent, the one parent-EDIT composition that replaced the sharing shape's canEdit member in ce3d0ad41. The area's anchor floor holds at 28 again. search.rls-both-personas (revision 7): clause 2 is re-authored to the counted total of a paged search (55382dc02 deleted the countable exception and its page-local estimate), and its source anchor names findData. Claude-Session: https://claude.ai/code/session_01ESFcvW3staenyBCGq7kcDH Co-authored-by: Claude --- .../areas/attachments-storage.json | 14 ++++++++++---- docs/qa/platform-checklist/areas/search.json | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/qa/platform-checklist/areas/attachments-storage.json b/docs/qa/platform-checklist/areas/attachments-storage.json index 7de6282a077..95962e4f25a 100644 --- a/docs/qa/platform-checklist/areas/attachments-storage.json +++ b/docs/qa/platform-checklist/areas/attachments-storage.json @@ -347,7 +347,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": 5, + "revision": 6, "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)"], @@ -355,7 +355,7 @@ "app": "showcase", "requires": [ "a files-enabled parent readable-but-not-editable by one persona (the dogfood matrix builds this with its own fixture; on stock showcase, projects are public_read_write so any member can edit — the read-not-edit split needs the fixture or a sharing tweak)", - "plugin-sharing present (canEdit is the authority; without it the gate degrades to parent READ visibility by design)" + "plugin-sharing present (its tri-state checkEdit verdict is the authority: allow admits and deny refuses, and an abstention, which means record sharing does not enforce on the parent, is judged by the security service's master-detail write check, whose allow and not_applicable alone admit; without plugin-sharing the gate degrades to parent READ visibility by design)" ], "provisioning": { "use": "qa-scratch-authz", @@ -378,7 +378,7 @@ ], "acceptance": [ { - "clause": "attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS — Salesforce parity (#2970 item 3): canEdit on the parent, not mere read", + "clause": "attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS — Salesforce parity (#2970 item 3): EDIT on the parent, not mere read", "oracle": "api", "verify": "the read-only member's insert is refused with that code, while the same member's LIST of the parent's attachments succeeds", "evidence": "the 403 + the successful list" @@ -423,7 +423,7 @@ "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts (clauses 0, 1, 2 and 4: attach without parent EDIT is 403 ATTACHMENT_PARENT_ACCESS while the same member's LIST still succeeds, uploaded_by is server-stamped over a spoofed value, delete splits uploader-vs-outsider, and FILES_DISABLED) + packages/qa/dogfood/test/attachments-unscoped-delete-gate.dogfood.test.ts, which now carries TWO blocks and pins CLAUSE 3 end to end. Block 1 (split fixture — the caller owns one row and may not touch the other): the predicate-less `{ multi: true }` delete is refused with ATTACHMENT_DELETE_DENIED / 403 / the 'Refusing an unscoped multi-delete of attachments' message and removes NOTHING, not even the row it WAS entitled to, so the refusal is authoritative and cannot land as a partial delete; a match-all `where: {}` reaches the same verdict by a DIFFERENT rule and asserts the PER-ROW message instead, which is what keeps the #9797 boundary honest (the whole-operation dispatch is deliberately scoped to a delete with no `where` at all, so `where: {}` stays a real query); and a predicate that MATCHES NOTHING is allowed through, the live half of the #4757 reasoning. Block 2 (the discriminating fixture — the caller uploaded EVERY row, so the per-row gate has nothing to refuse): the predicate-less delete is still refused, which is the only shape that proves 'refused OUTRIGHT' rather than 'every row happened to be denied'; the refusal also fires on an EMPTY table (the zero-match limb — the per-row dispatch is gated on matched rows, so a handler-only fix could never reach it); and both sides are held by the same caller sweeping the same rows with a SCOPED predicate successfully, plus a scoped `where: {}` over the empty table resolving. In both blocks the member is granted the sys_attachment delete bit first and the grant is asserted, because otherwise RBAC refuses ahead of the attachment hook and the file would be green for a reason it is not about. ⭐ Block 2 exists because block 1 provably cannot see this clause: ablating `dispatchUnscopedMultiWrite` from both registrations (rebuilt, dist-preflighted) leaves block 1 5/5 GREEN, while block 2 goes red BY RESOLVING — 'promise resolved \"2\"', which is #9719's measured wipe reproduced. Unit-level companion, also through a wired engine rather than a hand-built context: packages/services/service-storage/src/attachment-access-hooks.test.ts, describe 'unscoped multi-delete (no id, no where) — #4757 through the wired engine (#9719)'. CLAUSE 5 (the UPDATE-verb FILES_DISABLED re-point, new) has a HANDLER/ENGINE-level pin, NOT the wired dogfood/REST lane above: packages/plugins/plugin-audit/src/capability-gate-update-verb.test.ts, describe '[#10170] enable.files is asked on the UPDATE verb too' — runs a real ObjectQL (stub driver) rather than the hand-rolled fake in audit-writers.test.ts (whose registerHook ignores the { object } scope, so it cannot observe a registration-scope regression), and pins BOTH dispatch shapes (by-id AND predicate/per-row) refusing 403 FILES_DISABLED on re-point while leaving the row's parent_object unchanged, plus the positive (a re-point onto a files-ENABLED parent still succeeds) and the negative-control (an update that never names parent_object is not re-checked at all). This engine-level pin is why the checklist step above can isolate the refusal to FILES_DISABLED with confidence; it is NOT itself evidence about the wired REST PATCH lane, which is what clause 5's own oracle drives by hand." }, "source": [ "docs/plans/release-15.1-test-plan.md §A12 (attach 需 parent EDIT)", - "packages/services/service-storage/src/attachment-access-hooks.ts#canEdit (beforeInsert canEdit gate + uploaded_by stamping; beforeDelete/beforeUpdate uploader-or-editor + #4757 unscoped refusal reached via the `dispatchUnscopedMultiWrite` declaration (#9719/#9974), MULTI_WRITE_AUTH_LIMIT fail-closed; authorizes an update-verb re-point onto the NEW parent, the access half clause 5 relies on)", + "packages/services/service-storage/src/attachment-access-hooks.ts#mayEditParent (the one parent-EDIT question the beforeInsert attach gate, the beforeDelete/beforeUpdate parent-editor limb and an update-verb re-point all ask: plugin-sharing's tri-state checkEdit, never its two-state canEdit projection, with an abstention judged by the master-detail write check, #22513) + uploaded_by stamping; beforeDelete/beforeUpdate uploader-or-editor + #4757 unscoped refusal reached via the `dispatchUnscopedMultiWrite` declaration (#9719/#9974), MULTI_WRITE_AUTH_LIMIT fail-closed; authorizes an update-verb re-point onto the NEW parent, the access half clause 5 relies on)", "packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts#FILES_DISABLED (items 3, a, f; FILES_DISABLED)", "packages/plugins/plugin-audit/src/audit-writers.ts#enforceFilesCapability (enforceFilesCapability — the #2727 enable.files gate; [#10170] registered it on beforeUpdate as well as beforeInsert because the flag gates the TARGET object a write makes an attachment point at, not the create/update verb) — the FILES_DISABLED source for both clause 4 (create) and the new clause 5 (update/re-point)", "packages/spec/src/data/object.zod.ts#create (enable.files describe string, post-#10733: 'otherwise any write that makes an attachment target it is rejected (403 FILES_DISABLED) — a create and an update that re-points an existing attachment alike')", @@ -449,6 +449,12 @@ "date": "2026-08-25", "change": "added clause 5, the UPDATE half of the #10170 FILES_DISABLED gate, which this item was missing entirely: a re-point of an existing sys_attachment's parent_object onto the qa_nofiles fixture (already provisioned by qa-scratch-authz, #7670 — one throwaway record is inserted into it, no new fixture) now has its own step, persona note, negative entry and acceptance clause, isolated from ATTACHMENT_PARENT_ACCESS by running as admin (who can edit both the original and target parents). Filed because #10336/PR #10733 rewrote the enable.files describe string to name the update case explicitly ('a create and an update that re-points an existing attachment alike') while this item's create-only probe (clause 4) kept passing regardless of whether the update-verb enforcement #10170 added still held — the same gap the fixtures.qa-scratch-authz step-4 source re-quote (this file, above) makes visible. source/knownGaps/negative/automated updated to match, including the engine-level #10170 pin (capability-gate-update-verb.test.ts) that grounds the isolation claim.", "ref": "#10735" + }, + { + "revision": 6, + "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" } ] }, diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 67ca2138212..b0da0d1a9b6 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -211,7 +211,7 @@ "title": "Search honors RLS both ways: a restricted member gets no hits — and no total leakage — from rows they cannot see; the entitled persona finds the same rows", "since": "v15", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "api", "personas": ["seeded admin (sees all invoices)", "signed-up member bound to the contributor position via the qa-contributor-bound-member recipe (invoice_own_rows RLS: owner == current_user.email) — ⛔ NOT one of the seeded demo personas: Mei holds no position (PERMISSION_DENIED, object gate) and Ada holds auditor with viewAllRecords (all 12). See the recipe."], @@ -245,9 +245,9 @@ "evidence": "the paired responses + premise read" }, { - "clause": "no count leakage: a searched list's total/hasMore derive from the caller-scoped result set — with `search` present the protocol computes a page-local total from the RLS-filtered find (it never runs a raw count for searched lists), and engine.count itself rides the same read middleware (#2737)", + "clause": "no count leakage: a searched list's total/hasMore derive from the caller-scoped result set — with `search` present the protocol COUNTS the searched set (engine.count is handed the query's search and searchFields and expands them through the one expander find runs, #22790), and engine.count rides the same read middleware as find (#2737), so the total is the number of matching rows the caller can read", "oracle": "api", - "verify": "the member's paged totals reconcile exactly with the rows they can enumerate WHILE skip <= their visible count — the total is computed page-locally from the RLS-filtered find, so a skip past the visible set has no page to count and the reconciliation stops being the right question there; no response reveals the true seeded population (12 rows, plus whatever the run itself created — read the count from the seed rather than from this clause)", + "verify": "the member's paged total is the same number on every page and equals the matching rows they can enumerate across all pages, and hasMore is true on every page but the last; since #22790 the total is counted rather than estimated page-locally, so it reconciles at every skip, a skip past the visible set included; no response reveals the true seeded population (12 rows, plus whatever the run itself created — read the count from the seed rather than from this clause)", "evidence": "the paged responses + reconciliation table" }, { @@ -272,7 +272,7 @@ "examples/app-showcase/src/security/permission-sets.ts#invoice_own_rows (invoice_own_rows: owner == current_user.email, positions ['contributor'])", "examples/app-showcase/src/data/seed/index.ts (INV-1001..1012 owner spread; 'ada sees INV-1001/1002's lines but never linus's INV-1003')", "packages/objectql/src/engine.ts#expandSearchOnAst (expandSearchOnAst ANDs into ast.where; count() rides the read middleware — the #2737 total-leak fix)", - "packages/metadata-protocol/src/protocol.ts#countable (searched lists: countable = search == null → page-local total from the scoped find)" + "packages/metadata-protocol/src/protocol.ts#findData (the paged arm counts a searched list: engine.count is handed the query's search and searchFields, so total is the counted number of matches and hasMore agrees with it; the `countable` exception and its page-local estimate were deleted by #22790, PR #22857) + packages/rest/src/list-search-total-door.test.ts ('[#22790] a paged search reports the counted total', including 'a member counts only the matches it can read')" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: RLS × $search both-personas contract; total-leakage clause grounded in the protocol's page-local total for searched lists and the #2737 count middleware fix, persona provisioning gap recorded honestly", "ref": "claude/platform-test-checklist-ocwugl" }, @@ -295,6 +295,12 @@ "date": "2026-08-24", "change": "pins the second knownGaps correction on the area recipe this item `use`s, for the same reason revision 5 pinned the first: area-level recipes carry no revision/history of their own, so the revision of a consuming item is the only thing a run record can hold that text against (#10885 tracks giving recipes their own, and is deliberately not pre-empted here). qa-contributor-bound-member's first knownGap said records-forms.crud-roundtrip ⛔ CANNOT `use` this recipe because no cross-area spelling existed — true when written, false since the maintainer ruled option A on 2026-08-22 (#10593 gap 2): `provisioning.use` now also accepts `:`, and that item opts in as `search:qa-contributor-bound-member`. The note now describes a recipe with consumers in two areas, both machine-resolved. THIS item's own reference is untouched and still the bare same-area `qa-contributor-bound-member` — every existing spelling stays valid, which is part of the ruling. Steps, clauses, personas, fixtures and provisioning are all unchanged and no run verdict is invalidated", "ref": "#10593" + }, + { + "revision": 7, + "date": "2026-10-15", + "change": "clause 2's mechanism re-authored to the behaviour on main, and its source anchor re-pointed. #22790 (PR #22857, 55382dc02) deleted protocol.ts's `countable` exception and its page-local estimate: a paged search now runs engine.count with the query's search and searchFields, through the same expander find runs and the same read middleware, so `total` is the counted number of matches the caller can read. The clause said the protocol never counts a searched list, and revision 3 bounded the reconciliation to skip <= the visible count because the estimate had no page to count past it; both were true of the deleted arm and are false now, so the verify now asks for one total on every page that equals the enumerable matches, at any skip. The source anchor on protocol.ts named countable and no longer resolved (check:platform-checklist ABSENT SYMBOL); it now names findData, which holds the counting arm, plus the door pin that measures a member's searched total. The security property is unchanged: no response may reveal a row the caller cannot read. Clauses 1, 3 and 4, the steps, the personas and the fixtures are unchanged", + "ref": "#23593" } ] }, From 4fad0605ccc3dafcb207cd319ce6bad0dd9e3f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:48:28 +0000 Subject: [PATCH 2/3] docs(qa): re-author access-security.public-form-withdrawal-layers to the layers main has Revision 2. ADR-0131 retired the organization layer every clause of revision 1 composed against: the /meta doors hand the protocol no organization, b389e4355 deleted the intake-specific organization refusals and the env-wide row anchor this item cited, the sys_metadata family declares no organization column, and the anonymous doors read the environment alone. The stock end-to-end half now follows the re-authored dogfood pin (an organization-active administrator's save is environment-wide and its withdrawal closes both doors; the open control lands in the organization). The package layers are scored from the rest, metadata-protocol and metadata-core pins that replaced the deleted cases, and the anchors name what the files declare now. Claude-Session: https://claude.ai/code/session_01ESFcvW3staenyBCGq7kcDH Co-authored-by: Claude --- .../areas/access-security.json | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index fb03493eb90..e6083dd219a 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1519,104 +1519,98 @@ }, { "id": "access-security.public-form-withdrawal-layers", - "title": "A public form's withdrawal holds across metadata layers: an organization overlay cannot re-open a form the env-wide definition withdrew, only an explicit false withdraws, a package's shipped false withdraws, and the env-wide definition may open a package-closed form", + "title": "A public form's withdrawal holds across the layers of the environment's definition: an administrator's save is environment-wide whatever organization is active and its withdrawal closes the form, a withdrawal in any package's body of the view name closes it in every package, only an explicit false withdraws, a package's shipped false withdraws, and the package-less environment-wide copy may open a form a package ships closed", "since": "v17.7", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ "anonymous (no Authorization header, no cookie) on both public form doors, GET /api/v1/forms/:slug and POST /api/v1/forms/:slug/submit", - "platform admin (the seeded admin), saving the form at two scopes from one session: env-wide (no active organization) and in the Default Organization, which is the organization the anonymous doors read on a single-posture boot" + "platform admin (the seeded admin), saving the form from a session whose active organization is the Default Organization. Since ADR-0131 D6 that save is environment-wide, so the active organization is a premise the item holds, not a second layer it writes" ], "fixtures": { "app": "showcase", "requires": [ "the stock public form: examples/app-showcase/src/ui/views/inquiry.view.ts#InquiryViews, formViews.contact (enabled and allowAnonymous true, publicLink '/forms/contact-us'), served as the flattened view item showcase_inquiry.contact with its sharing at config.sharing", - "the stock single tenancy posture and its Default Organization, whose owner is the seeded admin (packages/plugins/plugin-auth/src/ensure-default-organization.ts#ensureDefaultOrganization). The doors resolve the organization through tenancy.defaultOrgId(), and the admin moves between scopes with POST /api/v1/auth/organization/set-active (organizationId null for env-wide). Under a walled posture the doors read no organization, and an org-scoped change of intake is refused for that reason instead (packages/metadata-protocol/src/protocol.ts#anonymousFormIntakeOrgScopeRefusal): out of this item", - "a boot you own (pnpm dev -- --fresh -p ): the steps leave an env-wide row and an organization overlay of the stock form behind, which would change access-security.public-form-intake's next run on a shared boot. Teardown is discarding the tempdir" + "the stock single tenancy posture and its Default Organization, whose owner is the seeded admin (packages/plugins/plugin-auth/src/ensure-default-organization.ts#ensureDefaultOrganization). The admin makes it active with POST /api/v1/auth/organization/set-active, and an anonymous submission lands in it. Under a walled posture the stock form is not offered at all, because its object cannot take an anonymous submission there (packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormIntakeUnavailability; packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts and public-form-withdrawal-walled.dogfood.test.ts own that half): out of this item", + "a boot you own (pnpm dev -- --fresh -p ): the steps leave an environment-wide row of the stock form behind, which would change access-security.public-form-intake's next run on a shared boot. Teardown is discarding the tempdir" ], "knownGaps": [ - "THE RULED KNOWN LIMIT (#21835 comment 6005722623, 「保持现状,写进文档」): the anonymous doors judge a form by the name of the view item they serve, and the save check runs only on an organization-scoped save or publish. So an organization overlay that was stored before the env-wide withdrawal, or that a rollback or commit revert restores (neither is gated), and that keeps the form open under a different key or slot than the env-wide definition, can still be served. content/docs/ui/public-data-collection.mdx states it under 'Known limit.'. This item neither stages nor scores it: a run that meets it records it against that ruling, never as a FAIL and never as a new finding. Withdrawing the form in that organization's overlay closes it", - "acceptance[4] and acceptance[5] (package-shipped forms) have no stock fixture: the showcase ships its public form open, and no stock package ships one closed. Score both from the pins named in their verify (oracle test) and record that the verdict rests on the pins. ⛔ Do not edit a package's artifact on disk to plant one", - "acceptance[2] (only an explicit false withdraws) is pinned at the doors and at the write door by unit tests only; the dogfood drives explicit false switches alone, so steps 8-9 are its only end-to-end reading. Its premise is the stored body: a view save stores the authored keys and does not materialise schema defaults (packages/metadata-protocol/src/protocol.ts#projectStorableViewBody), so an omitted allowAnonymous stays absent. If step 8's env-wide read serves an allowAnonymous key at all, the premise failed: record acceptance[2] blocked(fixture) instead of scoring it", - "two packages that each ship a view of the same name are outside this item's fixture and are not scored here. The package rule is the one the docs page's 'Known limit: packages and names' states at the run's commit; #21934 (open when this item was written) may narrow it" + "THE ORGANIZATION LAYER IS RETIRED, NOT A GAP. Revision 1 staged an organization overlay of the form beneath the env-wide definition, and an organization save door that refused a re-open. ADR-0131 removed both: the /meta doors hand the protocol no organization (D6, PR #22447), the protocol refuses every organization-scoped write (D6, PR #22515), the sys_metadata family declares no organization column (D7, PR #23469), and the anonymous doors read the environment alone (C7). On a v17-shape database os serve and os dev refuse to boot until os migrate organization-ownership --apply has run (packages/runtime/src/organization-ownership-boot-guard.ts#OrganizationOwnershipBootGuardPlugin), so the fresh boot this item asks for holds no organization-scoped metadata row. A run that meets one records an environment fault, never a FAIL of this item. The ruled known limit revision 1 recorded (#21835 comment 6005722623: an organization overlay stored before the env-wide withdrawal) has no stage left to occur on", + "acceptance[2] to acceptance[5] (the package layers) have no stock fixture: the showcase ships its public form from one package, open, and no stock package ships a second body of the name or ships it closed. Score them from the pins named in their verify (oracle test) and record that the verdict rests on the pins. ⛔ Do not edit a package's artifact on disk to plant one" ] }, "steps": [ - "boot showcase on a fresh DB you own, on the stock posture; sign in as the admin. Read GET /api/v1/auth/get-session and record session.activeOrganizationId as ORG (the Default Organization); if it is null, read the one sys_organization row as the admin and set it active. Prove the scope server-side before every save below: the save response's message names org=ORG for an organization save and 'env-wide' for an env-wide one", + "boot showcase on a fresh DB you own, on the stock posture; sign in as the admin. Read GET /api/v1/auth/get-session and record session.activeOrganizationId as ORG (the Default Organization); if it is null, read the one sys_organization row as the admin and set it active. Keep ORG active for every save below: the item asserts that the save is environment-wide all the same", "the probe, anonymous, from a client with no cookie jar and no Authorization header: GET /api/v1/forms/contact-us, then POST /api/v1/forms/contact-us/submit with name set to a fresh unique marker, plus email and message; capture each status and body code. Then, as the admin, count the showcase_inquiry rows carrying that marker (GET /api/v1/data/showcase_inquiry?name=MARKER). Baseline: run the probe once on the stock form (open: 200, 201, one row)", "as the admin, GET /api/v1/meta/view/showcase_inquiry.contact and keep the item with every key that starts with '_' removed. Every save below PUTs this body back to the same path and changes only config.sharing", - "precondition: set-active ORG; PUT the body with config.sharing.allowAnonymous true (an organization overlay that keeps the form open); run the probe", - "env-wide withdrawal: set-active null; PUT the body with config.sharing.allowAnonymous false, publicLink kept; run the probe", - "write door: set-active ORG; PUT the overlay again with allowAnonymous true (the overlay as it was before the withdrawal) and capture status and code; PUT it with allowAnonymous false and capture; PUT it with allowAnonymous true again and capture; after each refusal, GET the item at ORG and compare its config.sharing with the previous read; run the probe", - "the organization narrows for itself: set-active null; PUT the body with allowAnonymous true env-wide (open env-wide while the organization's overlay stays withdrawn from step 6); run the probe", - "explicit only, an absent switch: set-active null; PUT the body with the config.sharing.allowAnonymous key deleted (enabled true, publicLink kept); GET the item env-wide and confirm the served config.sharing carries no allowAnonymous key (the premise, see knownGaps); set-active ORG; PUT the body with allowAnonymous true and capture the status; run the probe", - "explicit only, no link: set-active null; PUT the body with config.sharing set to enabled false and allowAnonymous false and NO publicLink; set-active ORG; PUT the body with allowAnonymous true again and capture the status; run the probe", - "control, open at both layers: set-active null; PUT the body with allowAnonymous true (the stock sharing); set-active ORG; PUT allowAnonymous true; run the probe, and as the admin read the landed row's organization_id", - "run the pins named in automated.ref; acceptance[4] and acceptance[5] are scored from the metadata-protocol and metadata-core files" + "withdrawal from an organization-active session: with ORG active, PUT the body with config.sharing.allowAnonymous false, publicLink kept; capture the status and the save response's message, which names the scope the row landed in; run the probe. Then set-active null, GET the item and record its config.sharing (the withdrawal is the environment's, so it is served here too), and set-active ORG again", + "control, open: with ORG active, PUT the body with allowAnonymous true (the stock sharing); run the probe, and as the admin read the landed row's organization_id", + "run the pins named in automated.ref; acceptance[2] to acceptance[5] are scored from the rest, metadata-protocol and metadata-core files" ], "acceptance": [ { - "clause": "an env-wide withdrawal is not re-opened by an organization overlay: with the organization's overlay open (step 4's probe served) and the env-wide definition withdrawing the form with its link kept (step 5), both doors answer 404 with code FORM_NOT_FOUND and the probe leaves no row", + "clause": "an administrator's save is environment-wide whatever organization the session has active, and its withdrawal closes the form: with ORG active, the save that sets allowAnonymous false with the link kept answers 200 and names the environment-wide scope (never org=ORG), the item read with no organization active serves that withdrawal, and both doors answer 404 with code FORM_NOT_FOUND and the probe leaves no row", "oracle": "api", - "verify": "step 4's probe is recorded first (a form closed at both layers is closed for an unrelated reason); then step 5's GET and POST status and body code, and the admin's count for step 5's marker, which is 0. Mechanism: packages/rest/src/rest-server.ts#registerFormEndpoints reads the env-wide view list beneath the organization's read and drops a candidate that packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn finds withdrawn", - "evidence": "the probes of steps 4 and 5 (status, code, row count) and the two save responses with the scope each names" + "verify": "step 2's baseline probe is recorded first (a form already closed is closed for an unrelated reason); then step 4's save status and message, the no-organization read, and the GET and POST status and body code with the admin's count for the marker, which is 0. Mechanism: the /meta doors hand the protocol no organization, and an organization-scoped write is refused by packages/metadata-protocol/src/protocol.ts#organizationScopedWriteRefusal (403 NOT_OVERRIDABLE, ADR-0131 D6); packages/rest/src/rest-server.ts#registerFormEndpoints reads the environment's view list alone and drops a candidate that packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn finds withdrawn in that same list", + "evidence": "the baseline and step 4 probes (status, code, row count), the save response with the scope it names, and the no-organization read" }, { - "clause": "an organization-scoped save cannot leave the withdrawn form open: re-saving the overlay as it was before the withdrawal, and re-opening it after a withdrawal, both answer 403 with code NOT_OVERRIDABLE and change nothing; the save that keeps the form withdrawn answers 200; both doors stay closed", + "clause": "a form open in the environment is served, and its submission lands in the organization the doors resolve: with ORG active and the body saved with both switches on, both doors accept (GET 200, POST 201, one row) and the landed row's organization_id is ORG", "oracle": "api", - "verify": "step 6's three responses (status, then the code at the top level or under error), the organization reads around each refusal (unchanged), and step 6's probe (404 FORM_NOT_FOUND twice, no row). Mechanism: packages/metadata-protocol/src/protocol.ts#anonymousFormIntakeReopenRefusal, reached from saveMetaItem and from the draft promotion of publishMetaItem; the draft-promotion half is pinned by the metadata-protocol file only", - "evidence": "step 6's save responses, the organization reads and the probe" + "verify": "step 5's save status, its probe and the admin's read of the landed row", + "evidence": "the probe and the landed row read" }, { - "clause": "only an explicit false withdraws: an env-wide definition that keeps the link but leaves allowAnonymous absent, or a sharing that names no public link, withdraws nothing, so the organization-scoped save that opens the form answers 200 and both doors serve it (GET 200, POST 201, one row)", - "oracle": "api", - "verify": "steps 8 and 9: the premise read (no allowAnonymous key served env-wide), the organization save's status, and each probe. Mechanism: packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormExplicitWithdrawals (a sharing withdraws only when it keeps a non-empty publicLink and sets enabled or allowAnonymous to false)", - "evidence": "the premise read, the two organization save responses and the two probes" + "clause": "only an explicit false withdraws (#21835 comment 5994082238, 「不算,写进文档」): another package's body of the view name that keeps the link with enabled true and allowAnonymous absent, or that names no public link, closes nothing, so the open form is still served (GET 200, POST 201)", + "oracle": "test", + "verify": "pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/public-form-withdrawal.test.ts, the cases 'only an explicit false withdraws: a body with the link, enabled true and allowAnonymous absent closes nothing' and 'not a withdrawal: another package's body with the public link cleared leaves the form served'; and pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2 src/anonymous-form-intake.test.ts, the cases 'only an explicit false withdraws: an absent switch is not a withdrawal' and 'not a withdrawal: no body of the row, no sharing, the link cleared'. Mechanism: packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormExplicitWithdrawals (a sharing withdraws only when it keeps a non-empty publicLink and sets enabled or allowAnonymous to false). No stock fixture (knownGaps)", + "evidence": "the two test runs, naming the cases above as passed" }, { - "clause": "an organization can always narrow for itself, and a form open at every layer is served: open env-wide with the organization's overlay withdrawn, both doors answer 404 FORM_NOT_FOUND; open at both layers, both doors accept and the landed row's organization_id is ORG", - "oracle": "api", - "verify": "step 7's probe (closed) and step 10's probe (open) with the landed row's organization_id", - "evidence": "the two probes and the landed row read" + "clause": "a withdrawal in any package's body of the view name closes the form in every package, failing closed (the docs' 'Known limit: packages and names'): the environment's list holds one item per package of the name, and the doors serve no copy once one of them withdraws it; a different view that publishes the same slug closes nothing", + "oracle": "test", + "verify": "the rest run, the describe 'a public form withdrawal is a kill switch: one package's withdrawal of a view name closes its form': the cases 'another package's withdrawal of the same view name closes this package's form (tenancy org)' and '(tenancy no-org)', 'withdrawn through `enabled: false` in another package: closed' and 'another view withdrawing the same slug does not close this view's form', with the control 'control: every package's body of the view name open serves the form'; and pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.org-scoped-write-refused.test.ts, the describe 'two packages shipping a view name', the case 'the doors serve no copy once one package withdraws the name' under both registry orders. The list the doors judge against is packages/metadata-protocol/src/protocol.ts#getMetaItems. No stock fixture (knownGaps)", + "evidence": "the two test runs, naming the cases above as passed" }, { - "clause": "a package's shipped false withdraws (#21835 comment 6005722623, 「包内的 false 算显式关闭」): a package artifact parsed by the stack schema (strict defineStack, the default) whose form keeps its publicLink without switching enabled on carries the schema default enabled false, which counts as an explicit withdrawal, so an organization-scoped save that opens it answers 403 NOT_OVERRIDABLE and stores nothing. An artifact that reached the runtime unparsed (defineStack with strict false, a hand-built manifest) is judged as written", + "clause": "a package's shipped false withdraws (#21835 comment 6005722623, 「包内的 false 算显式关闭」): a package artifact parsed by the stack schema (strict defineStack, the default) whose form keeps its publicLink without switching enabled on carries the schema default enabled false, which counts as an explicit withdrawal, and an artifact that reached the runtime unparsed (defineStack with strict false, a hand-built manifest) is judged as written; a shipped withdrawal in the environment's list closes the form beside another package's open copy", "oracle": "test", - "verify": "pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.org-scoped-write-refused.test.ts, the 'single: a package-shipped form' cases 'the parsed artifact carries an explicit `false` that keeps the link' and 'a schema-parsed `false` is a withdrawal: an org-scoped save that opens it is refused'; and pnpm --filter @objectstack/metadata-core exec vitest run --maxWorkers=2 src/anonymous-form-intake.test.ts, the case 'a schema-parsed `false` that keeps the link IS a withdrawal (a package artifact fails closed)'. No stock fixture (knownGaps)", + "verify": "the metadata-core run, the case 'a schema-parsed `false` that keeps the link IS a withdrawal (a package artifact fails closed)'; and the metadata-protocol run, the describe 'each package's copy of a view container expands into its own package's slot', its block '(a) one package's env-wide copy is saved with the form open, and another package ships it withdrawn', every case ending ': the env-wide list holds the shipped withdrawal beside the copy, and the doors serve no copy'. No stock fixture (knownGaps)", "evidence": "the two test runs, naming the cases above as passed" }, { - "clause": "the env-wide definition may open a package-closed form (#21835 comment 6005722623, 「不算,环境级是开关」): the package artifact is part of the env-wide definition, not a layer beneath it, so an env-wide save with both switches on is accepted, the env-wide list the doors read then serves that open body as the only item of its name, and an organization-scoped save that keeps it open is accepted", + "clause": "the environment-wide copy is the switch over a form a package ships closed (#21835 comment 6005722623, 「不算,环境级是开关」): a package-less environment-wide copy stands in for every package with no copy of its own, so saved with both switches on it opens a form a package ships closed; a package's own copy still serves that package's slot ahead of it, so a withdrawal saved there keeps the form closed", "oracle": "test", - "verify": "the same metadata-protocol run, the case 'the env-wide definition is the switch: an env-wide save opens it, and the env-wide list serves that body'. The row anchor that makes the artifact the env-wide body is packages/metadata-protocol/src/protocol.ts#envWideRawViewRows (for a form one package ships: the active env-wide row of the name, else that package's artifact). No stock fixture (knownGaps)", - "evidence": "the metadata-protocol run, naming the case above as passed" + "verify": "the metadata-protocol run, the block '(c) a package-less env-wide copy stands in for every package with no copy of its own': on both kernels, the case ending ': the package-less copy serves each package's slot, so the administrator's switch opens the form for every package', and the cases ending ': a package's own copy serves that package's slot ahead of the package-less copy'. The stand-in is packages/metadata-protocol/src/protocol.ts#servedViewExpansion. No stock fixture (knownGaps)", + "evidence": "the metadata-protocol run, naming the cases above as passed" } ], "negative": [ - "a 200 or 201, or a landed row, at either door in step 5 or step 6 is the #21835 regression (an organization overlay re-opening an env-wide withdrawal): FAIL", - "a 200 to an organization-scoped save in step 6 that leaves the form open is a FAIL of the write door even while the doors stay closed: the platform accepted a change it will never honour", - "with the premise read holding, a closed door in step 8 or step 9 is a FAIL of the explicit-only rule (#21835 comment 5994082238, 「不算,写进文档」): an absent switch or a missing link withdraws nothing", + "a 200 or 201, or a landed row, at either door after step 4's withdrawal is a FAIL: the environment-wide withdrawal did not hold (the #21835 class, a layer re-opening a withdrawn form)", + "a step 4 save that answers 200 but names org=ORG, or a no-organization read that still serves allowAnonymous true, is a FAIL of ADR-0131 D6: an administrator's save lands environment-wide whatever organization is active", + "a 403 NOT_OVERRIDABLE to the save in step 4 or step 5 is a FAIL of the /meta doors: the save reached the protocol organization-scoped, which no door may hand it", "a door probed with the admin's cookie or bearer token scores the admin, not the public: re-run it anonymously (auth-state-leak)", - "the ruled known limit (knownGaps, first entry) is not a FAIL" + "acceptance[2] to acceptance[5] scored from the stock boot prove nothing: the showcase ships the form from one package, so no second body of the name exists to compose. Score them from their pins", + "an organization-scoped metadata row met on the boot is an environment fault (knownGaps, first entry), not a FAIL of this item" ], "traps": [ "auth-state-leak" ], "automated": { "kind": "dogfood", - "ref": "packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts ('showcase: a public form withdrawal at any metadata layer holds', 5 cases: acceptance[0], [1] and [3] end to end on a showcase boot with an organization) + packages/rest/src/public-form-withdrawal.test.ts ('a public form withdrawal is a kill switch: layering only narrows intake': the doors, including 'only an explicit false withdraws: env-wide link + enabled true with allowAnonymous absent does not close the org's open form' and 'not a withdrawal: the public link cleared env-wide leaves the organization's open form served') + packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts ('org-scoped anonymous form intake changes the anonymous doors cannot see': the write door, including 'single: a package-shipped form') + packages/metadata-core/src/anonymous-form-intake.test.ts (the anonymousFormIntakeWithdrawnIn cases). acceptance[2] has no end-to-end pin, and acceptance[4] and [5] have no stock fixture (knownGaps)" + "ref": "packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts ('showcase: a public form withdrawal at any metadata layer holds', 3 cases: the precondition, acceptance[0] through the save's env-wide message, and acceptance[1], end to end on a showcase boot with an organization active) + packages/rest/src/public-form-withdrawal.test.ts (the doors: '[#21331] public form withdrawal reaches every intake door', including '[ADR-0131 C7] no view read names an organization, whatever the tenancy answers: one environment read per door', and 'a public form withdrawal is a kill switch: one package's withdrawal of a view name closes its form') + packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts ('[ADR-0131 D6] no organization-scoped metadata write is accepted', 'two packages shipping a view name' and 'each package's copy of a view container expands into its own package's slot', blocks (a) to (f)) + packages/metadata-core/src/anonymous-form-intake.test.ts (the anonymousFormIntakeWithdrawnIn cases). The no-organization read of step 4 has no end-to-end pin, and acceptance[2] to acceptance[5] have no stock fixture (knownGaps)" }, "source": [ - "packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn (the one judgement the doors and the write door share: a body of the same view name withdraws a candidate matched by slot or by slug; the package is not compared)", + "packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn (the one judgement the doors make over the environment's list: a body of the same view name withdraws a candidate matched by slot or by slug; the package is not compared)", "packages/metadata-core/src/anonymous-form-intake.ts#anonymousFormExplicitWithdrawals (explicit only: a sharing that keeps its publicLink and sets enabled or allowAnonymous to false; a schema-parsed artifact carries the default enabled false, which counts)", - "packages/rest/src/rest-server.ts#registerFormEndpoints (resolveFormBySlug: the organization from tenancy.defaultOrgId(), and the env-wide view list read beneath it as the layer; a withdrawn form answers 404 FORM_NOT_FOUND on both doors)", - "packages/metadata-protocol/src/protocol.ts#anonymousFormIntakeReopenRefusal (the write door: 403 NOT_OVERRIDABLE, judged on the expanded body against the env-wide list and on the raw body against the row anchor; reached from saveMetaItem and from the draft promotion, never from rollbackMetaItem or revertCommit, which is the second half of the ruled known limit)", - "packages/metadata-protocol/src/protocol.ts#envWideRawViewRows (the row anchor, for a form one package ships: the active env-wide row of the name, else that package's artifact. How it resolves when several packages ship the name is #21934's)", - "content/docs/ui/public-data-collection.mdx, section '4. Withdraw a public form' (the published rules and both known limits)", - "#21835 (the 17.7 regression; rulings in comments 5994082238 and 6005722623) · PR #21864 (the fix, merge 3c7785d4ab) · #21934 (open when this item was written: package-identity follow-ups of the same fix)", - "sibling: access-security.public-form-intake owns the single-layer half (whitelist, forged-anchor strip, publicFormGrant, unpublish kills the link). This item owns how a withdrawal composes across layers" + "packages/rest/src/rest-server.ts#registerFormEndpoints (resolveFormBySlug: the environment's view list alone, judged against itself as the layer beneath each candidate; the Default Organization's legacy read was deleted with ADR-0131 C7, and the tenancy service is still read for the posture; a withdrawn form answers 404 FORM_NOT_FOUND on both doors)", + "packages/metadata-protocol/src/protocol.ts#getMetaItems (the environment's view list the doors read: one item per package that ships a view name, ADR-0048; a package-less copy stands in for every package with no copy of its own, packages/metadata-protocol/src/protocol.ts#servedViewExpansion)", + "packages/metadata-protocol/src/protocol.ts#organizationScopedWriteRefusal (every organization-scoped write refused 403 NOT_OVERRIDABLE, ADR-0131 D6; PR #22515 deleted the intake-specific organization refusals and the row anchor revision 1 cited, and since PR #22447 the /meta doors hand the protocol no organization, so an organization-active administrator's save is environment-wide)", + "content/docs/ui/public-data-collection.mdx, section '4. Withdraw a public form' (the published rules and the known limit for packages and names; when this revision was written its 'Known limit: legacy copies' paragraph still described the Default Organization read that ADR-0131 C7 deleted from the doors)", + "#21835 (the 17.7 regression; rulings in comments 5994082238 and 6005722623) · PR #21864 (the fix, merge 3c7785d4ab) · #21934 (package-identity follow-ups of the same fix, closed) · ADR-0131: PR #22447 (D6 stage S3), PR #22515 (D6 stage S4, b389e4355) and PR #23469 (D7, dd29bbe60) retired the organization layer revision 1 staged", + "sibling: access-security.public-form-intake owns the single-layer half (whitelist, forged-anchor strip, publicFormGrant, unpublish kills the link). This item owns how a withdrawal composes across the bodies the environment's definition holds of one view name" ], "history": [ { @@ -1624,6 +1618,12 @@ "date": "2026-10-06", "change": "new: PR #21864 made a public form's withdrawal a kill switch across metadata layers, and no item asserted it; access-security.public-form-intake covers the single-layer withdrawal only. Adds the #21835 row of #21932: an env-wide withdrawal is not re-opened by an organization overlay (both doors and the write door), only an explicit false withdraws, a package's shipped false withdraws, and the env-wide definition may open a package-closed form. The ruled known limit (#21835 comment 6005722623) is recorded as a knownGap, not a clause", "ref": "#21932" + }, + { + "revision": 2, + "date": "2026-10-15", + "change": "re-authored to the behaviour on main: ADR-0131 retired the organization layer every clause of revision 1 composed against. The /meta doors hand the protocol no organization (PR #22447); the protocol refuses every organization-scoped write, and PR #22515 (b389e4355) deleted the three protocol.ts symbols this item anchored, the intake-specific organization refusals and the env-wide row anchor (check:platform-checklist ABSENT SYMBOL on all five citations); the sys_metadata family declares no organization column (PR #23469); and the anonymous doors read the environment alone. Revision 1's clauses 0, 1 and 3 (an organization overlay beneath an env-wide withdrawal, the organization save door's 403, an organization narrowing for itself) describe a layer that can no longer be written, and its steps 4 to 10 PUT saves that now land environment-wide. Revision 2 keeps the capability the id names, a withdrawal no other layer re-opens, on the layers main has. The stock end-to-end half follows the dogfood pin's re-authored shape: an organization-active administrator's save is environment-wide and its withdrawal closes both doors, and the open control lands in the organization. The package layers are scored from the rest, metadata-protocol and metadata-core pins that replaced the deleted cases: one item per package of a view name in the environment's list (new as a clause; revision 1 left two packages of one name unscored while #21934 was open), a package's shipped false, and the package-less copy as the switch. The explicit-only clause moves from an end-to-end organization stage to those pins. The ruled known limit of revision 1 has no stage left to occur on and is recorded in knownGaps. Anchors re-pointed at what the files declare now: organizationScopedWriteRefusal, getMetaItems, servedViewExpansion, anonymousFormIntakeUnavailability and the v18 boot guard", + "ref": "#23593" } ] }, From 832cfc6576e0ba22f9b66459d2be1ab4bb79fd96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 09:49:10 +0000 Subject: [PATCH 3/3] docs(qa): a 403 to the public-form withdrawal item's stock save is read before it is attributed Claude-Session: https://claude.ai/code/session_01ESFcvW3staenyBCGq7kcDH Co-authored-by: Claude --- docs/qa/platform-checklist/areas/access-security.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index e6083dd219a..455ca03171d 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1590,7 +1590,7 @@ "negative": [ "a 200 or 201, or a landed row, at either door after step 4's withdrawal is a FAIL: the environment-wide withdrawal did not hold (the #21835 class, a layer re-opening a withdrawn form)", "a step 4 save that answers 200 but names org=ORG, or a no-organization read that still serves allowAnonymous true, is a FAIL of ADR-0131 D6: an administrator's save lands environment-wide whatever organization is active", - "a 403 NOT_OVERRIDABLE to the save in step 4 or step 5 is a FAIL of the /meta doors: the save reached the protocol organization-scoped, which no door may hand it", + "a 403 to the save in step 4 or step 5 on the stock boot is a FAIL (the dogfood pin saves the same body with 200); read its message before attributing it: one naming an organization means the save reached the protocol organization-scoped, which no /meta door may hand it", "a door probed with the admin's cookie or bearer token scores the admin, not the public: re-run it anonymously (auth-state-leak)", "acceptance[2] to acceptance[5] scored from the stock boot prove nothing: the showcase ships the form from one package, so no second body of the name exists to compose. Score them from their pins", "an organization-scoped metadata row met on the boot is an environment fault (knownGaps, first entry), not a FAIL of this item"