Repository navigation
fix(rest,metadata-protocol): a public form's intake withdrawal at any metadata layer holds; layering can only narrow intake - #21864
Conversation
… metadata layer holds; layering can only narrow intake Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…showcase boot Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
📓 Docs Drift CheckThis PR changes 3 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ee6285d26a5185603e2f201a7a97f39664b99ca0 && git checkout ee6285d26a5185603e2f201a7a97f39664b99ca0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e33ee7c5936e35a38158a7f9fdbcbd4445797a8 7882eef683e0d415c065f50049f705280a5771d6 && git checkout -B drift-repro 9e33ee7c5936e35a38158a7f9fdbcbd4445797a8 && git merge --no-ff 7882eef683e0d415c065f50049f705280a5771d6
node scripts/docs-audit/affected-docs.mjs --json 9e33ee7c5936e35a38158a7f9fdbcbd4445797a8
|
…ening Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…d only when explicit A withdrawal is judged by view identity (name + slot) across layers: other views sharing a slug never close each other. Only a sharing that keeps the link and clears a switch withdraws; a linkless sharing (raw or schema-parsed) does not. The org-scoped write refusal judges the doors' verdict for every form the save leaves open, over the container's list-read expansion. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…withdrawal is refused Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs read: card #21835 (body and all six comments: triage, claim, os-dev-report round 1, claim correction, the two refining rulings, os-dev-report round 2), PR #21864 (body, 10-file list, net diff against ① Derived judgmentsAccept-set changes:
Public-surface changes (package
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…r slug, and the write door anchors identity on the stored row Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs: card #21835 (body and all 9 comments, rulings 5994082238 and 6005722623, os-dev-reports through round 5, 6007561084), PR #21864 (body, 11 files, net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL FAIL reasons:
Pending check-runs at this head (not waited on): Check Changeset (1 of 3 runs) · Test Core 1/6 to 6/6 · Dogfood Regression Gate 1/3 to 3/3 · Dogfood Verify CLI · Temporal Conformance (live PG + MySQL) · Build Core · Type Check · workspace · Type Check · consumer gates · Type Check · debt ledger · Lint & Repo Gates. |
…rm-withdrawal-kill-switch
…ckage again The cross-package skip in the withdrawal judge is removed, with the package stamping in the org-scoped write door that only fed it. The write door's row anchor reads the env-wide row of a name without a package key, so the skip could leave it judging nothing when several packages ship the same view name. Without it, a withdrawal of a name closes that name's form in every package: it may over-close another package's form of the name, never under-close. The pins assert that, and that a row-anchored rename by a package-bound org save is refused when two packages ship the name. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…imit) The package rule is replaced by the known limit: a withdrawal of a view name closes that name's form in every package, which may over-close but never under-closes. Per-package precision is tracked separately. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
…self-test reads its mkdtemp base Main is red on the dispatch-gates self-test: the dogfood per-file cwd setup takes its mkdtempSync base from a value the scratch-dir scan cannot read. These three files are ported unchanged from the open fix branch (refs/pull/21935/head at 2edc5d5) so this branch's gates read green; they merge away when that fix lands on main. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Contract reviewServed-tier: Inputs: card #21835 (body and all 12 comments: rulings 5994082238 and 6005722623, claim corrections 5993499550 and 6007635663, os-dev-reports through round 6, 6008895615), PR #21864 (body, 14 files, net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Check-runs at this head: 22 success, 4 skipped, 0 failed. 13 are pending and were not waited on: Test Core 1/6 to 6/6, Dogfood Regression Gate 1/3 to 3/3, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Type Check · workspace and Type Check · consumer gates. Landing still needs every check green. |
…ctory (objectstack-ai#21919) Fixes objectstack-ai#21914 Clause-②: no ## What changes Every dogfood test file now runs in its own temporary working directory. The suite fails when any file leaves `.objectstack/data` in `packages/qa/dogfood`. - **`test/per-file-cwd.setup.ts`** (new) is a `setupFiles` entry, wired explicitly in BOTH projects of `vitest.config.ts`, because inline projects inherit nothing from the root block. `shared-showcase` keeps `isolate: false`; the module still runs once per file there. - At module top level, before the test file's own imports, it creates a directory under the run's temporary root and `chdir`s into it. - In `afterAll` it restores the previous cwd. That `afterAll` is also **the guard**: it THROWS when `packages/qa/dogfood/.objectstack/data` exists. The message names the directory, its entries and the remedy. It also says the named file may be a concurrent one on another worker rather than the writer, and whether the directory was already present when the file started. - **`test/per-file-cwd.global-setup.ts`** (new) is a root-level `globalSetup`. It runs once per run, covering both projects and each `OS_TEST_SHARD` slice (measured). - At the START it clears a stale `packages/qa/dogfood/.objectstack`, so a developer's earlier run never reds the suite. - It creates one temporary root for the run and hands it to the workers with `provide` / `inject`. - At the END it removes that root, which is **where the per-file directories are removed**. The removal is run-level, not per file, because the memoized `shared-showcase` boot keeps its SQLite handles open in the directory of the file that booted it. - The teardown judges nothing (see Evidence: a throwing teardown is a false green). - **`vitest.config.ts`** wires the two modules. A header section explains why there are two halves and why the guard is not in the teardown. - **`test/enterprise-organizations.ts`**: the module-level `probeOrganizations()` now passes this package's root as `hostRoot`, resolved from the module's location (`new URL('..', import.meta.url)`), not the cwd. This was measured to be needed; see Evidence. No per-file edits. The five files the card names, and the other 87 measured writers, are covered by the module with no change of their own. Test isolation only: `@objectstack/dogfood` is `private: true`, so no published package moves and there is no changeset (`skip-changeset`). ## The invariant for every dogfood author - **Each test file runs in its own temporary cwd.** Anything it writes relative to the cwd is its own, no other file sees it, and it is removed at the end of the run. A file needs no `mkdtemp` / `chdir` of its own. - **A package-relative read must resolve from the module's location** (`new URL('..', import.meta.url)`, `import.meta.dirname`), never from `process.cwd()`. The cwd is a temporary directory. - **A file that writes into `packages/qa/dogfood/.objectstack/data` fails the run.** That happens through an absolute path built from the package root, or through a `process.chdir()` back to the package directory before a boot. The fix is to write relative to the file's own cwd. - Files that already `chdir` into a temp dir of their own still work, because they restore to the per-file directory. Their own `chdir` is now redundant and harmless. ## Why (measured) A per-file probe over the whole suite measured 92 test files leaving `.objectstack/data/showcase_external.db` in the package directory, not the five the card names: - 7 leave the populated federated fixture (24576 B, 2 tables): the card's five, plus `showcase-demo-personas-loginable` and `showcase-demo-personas-membership`, which pass `onEnable` in the bundle. - 85 leave an empty SQLite file (4096 B, 0 tables). The showcase's declared external datasource has a cwd-relative filename, and its auto-connect creates the file on every showcase boot, `onEnable` or not. A later boot on the same runner found or missed the federated tables depending on which files ran before it, and that ordering is how PR objectstack-ai#21905 went red only on dogfood shard 3/3. The seat chose this route (one module) and this guard (comment `6004950414` on objectstack-ai#21914), on the dev's measurement (comment `6004909676`). ## Evidence All runs are at head `967ce88a`, under the shared verify lock, from a clean package directory. - **Whole suite**: `pnpm --filter @objectstack/dogfood test` gave `Test Files 205 passed | 1 skipped (206)` and `Tests 1591 passed | 9 skipped (1600)`. Afterwards `packages/qa/dogfood/.objectstack` does not exist, and no `os-dogfood-run-*` root is left in the temp dir. - **CI's three-shard split**: CI's dogfood leg exports `OS_TEST_SHARD=k/3` and `vitest.config.ts` turns it into vitest's `shard`. Here each shard ran as `OS_TEST_SHARD=k/3 pnpm --filter @objectstack/dogfood test`: the same vitest selection, without turbo, so no cached replay. Each exited 0 and left no `.objectstack`: | shard | Test Files | Tests | |---|---|---| | 1/3 | 69 passed (69) | 507 passed (507) | | 2/3 | 69 passed (69) | 461 passed, 1 skipped (462) | | 3/3 | 67 passed, 1 skipped (68) | 623 passed, 8 skipped (631) | The three add up to the whole run: 206 files, 1600 tests. - **Ablation (H4)** through `scripts/ablation-replace.mjs`, wrap mode. The central `process.chdir(...)` was replaced by the bare `mkdtempSync(...)`: anchor count 1 to 0, blob `0991eb9c` to `ee5a65be`. - With the chdir dropped, `showcase-external-autoconnect` and `showcase-search` ran: `Test Files 2 failed (2)`, `Tests 8 passed (8)`, exit 1. Each failed in the guard: `.../packages/qa/dogfood/.objectstack/data exists after this test file ran. Entries: showcase_external.db` (plus `-shm` / `-wal` for the shared-showcase file). - Restore was proven by the tool: blob after restore equals HEAD (`0991eb9c`), and `git diff HEAD` is empty. - The same two files then gave `2 passed`, exit 0, and left nothing. - No build step is involved: vitest loads the mutated module from source. - **Stale directory**: `.objectstack/data/x.db` was planted, then 9 files were run. Result: `Test Files 9 passed (9)`, exit 0, nothing left (the globalSetup cleared it). - **Census**: those 9 files are the 7 populated-fixture writers plus `showcase-search` and `showcase-permission-zoo`, both `shared-showcase` files. - **`hostRoot` line, measured both ways**, running `rls-multitenant`, `org-create-default-team` and `enterprise-organizations.test`: - Without the line (commit `4d07dc29`), the skip text read `not resolvable from /tmp/os-dogfood-run-.../file-...` and told the reader to declare the package in that temp directory's `package.json`. - With it (`967ce88a`), the text names `packages/qa/dogfood/`. - The verdict is the same both ways (skipped), because no framework package declares `@objectstack/organizations`. - **Guard placement**: a throwing `globalSetup` teardown was measured on vitest 4.1.11 to print `error during close` and still exit 0, a false green. So the guard is the per-file `afterAll`. (A teardown that sets `process.exitCode = 1` does exit 1, but the summary still reads all-passed.) - **Typecheck and lint**: `pnpm --filter @objectstack/dogfood typecheck` is green, and `tsc --listFiles` includes both new modules and `enterprise-organizations.ts`. `pnpm lint` exits 0. - **Gates**: 130 commands at `967ce88a`, the dispatch list plus `pnpm check:dispatcher-error-vocabulary` from `dispatch-gates --commands`. `dispatch-gates --ran`: `48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED`. - `check:dual-build-cjs-loads` and `check:published-readme-exports` first exited 3 (dist prerequisite: 7 packages unbuilt). After building those 7, both exit 0. - The three PR-context scripts (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) are re-run with this PR's context; the results are in the report on the card. ## Open PRs that add dogfood files | PR | new file | boots the showcase | own `chdir` | under this PR | |---|---|---|---|---| | objectstack-ai#21864 | `showcase-public-form-withdrawal-layers.dogfood.test.ts` | yes | no | Covered with no author action. Without this PR it would leave `.objectstack/data` in the package directory. | | objectstack-ai#21917 | `organization-delete-federated-fixture.dogfood.test.ts` | yes, with `onEnable` | yes | Unaffected; its own `chdir` is redundant. | | objectstack-ai#21906 | `external-import-code-datasource-namespace.dogfood.test.ts` (also edits three `external-*` files) | yes, with `onEnable` | yes | Unaffected. None of its files is edited here. | | objectstack-ai#21877 | `datasource-contractless-credentials.dogfood.test.ts` | yes | yes | Unaffected. | | objectstack-ai#21897 | `flow-node-config-values-at-registration.dogfood.test.ts` | no (fixture stack) | no | Runs in its own temp cwd; it reads nothing relative to the cwd. | None of these files reads a package-relative path through `process.cwd()`. Only their own `prevCwd` captures do. ## Acceptance notes - **Observation, not filed.** The showcase's external datasource is declared read-only (`schemaMode: 'external'`, `allowWrites: false`). Its auto-connect CREATES a missing `.objectstack/data/showcase_external.db`, plus `-wal` / `-shm` (measured on 85 harness boots). - The declaration's own comment in `showcase-external.datasource.ts` says that if the fixture file cannot be opened, "the boot stops with that as the reason rather than serving a showcase whose federation pages are quietly dead". - It was measured only through the verify harness's `bootStack`, never at a public door (`os start` / `os dev`), so it stays here. - **Latent, unreachable today.** `bootStack(..., { multiTenant: true })` also defaults its `hostRoot` to the cwd: `rls-multitenant.dogfood.test.ts:79`, and `attachments-permission-matrix.dogfood.test.ts:766` through `bootFixture`. Both are gated on `organizationsAvailable`, which is false in this repository because no framework package may declare `@objectstack/organizations` (ADR-0132). A run that declares it in this package would need those boots to pass the package root too. Carrier: whoever declares it. - The own `chdir` in `external-validate-sees-runtime-save`, `external-import-destructive-remedy`, PR objectstack-ai#21906's file and PR objectstack-ai#21917's file is now redundant. It is left untouched and can be removed once objectstack-ai#21906 lands. Carrier: the `domain:cli` seat. - Attribution limit: under parallel workers, the guard can name a file that ran at the same time as the writer. The message says so, and says whether the directory was already present when the named file started. --- _Generated by [Claude Code](https://claude.ai/code/session_01RWZbGvPFcRKvUqASZtunCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…jectstack-ai#21943) Part of objectstack-ai#21932 Clause-②: no ## What changes The platform checklist gains items for the rules the 17.7 pre-release security follow-up landed, and two re-checks from the card are resolved. All edits are in `docs/qa/platform-checklist/areas/*.json`. `automation.json` is untouched (open PR objectstack-ai#21928 holds it). | Card row | Disposition | Item | |---|---|---| | objectstack-ai#21792 (PR objectstack-ai#21809) settings audit and secret-valued settings | new item | `platform-core.settings-audit-secret-fingerprint` | | objectstack-ai#21846 (PR objectstack-ai#21872) implicit account linking | new item | `identity-auth.implicit-account-linking-ownership` | | objectstack-ai#21839 (PR objectstack-ai#21890) share-link password | three clauses added, rev 4 to 5 | `access-security.share-link-capability-tokens` | | objectstack-ai#21836 (PR objectstack-ai#21879) global search skips unreadable objects, plus the two cases objectstack-ai#21880 lists | new item | `search.global-search-skips-unreadable` | | re-check 1: A2 / A7 and the plugin-driver boundary | rev 2 to 3 | `integration-system.datasource-credential-refusal-matrix` | | re-check 2: the objectstack-ai#21845 CLI and quorum N1 notes | already applied by objectstack-ai#21891, no edit | `cli.scaffold-first-run`, `cli.scaffold-console-first-paint`, `approvals.quorum-m-of-n` | Each item states rules, not reproductions. Withheld security detail stays out. ### Grounding, per row - **Settings audit fingerprint.** Both ledgers record the keyed digest for a secret-valued setting, or no fingerprint when none is available, and never the value or an unkeyed hash. Grounded in `settings-service.ts#secretAuditDigest`, `config-change-audit.ts#CONFIG_CHANGE_ACTION` and the contract text at `crypto-provider.ts#keyedDigest`. The pin is `settings-audit-secret-digest.test.ts` (7 cases). The offline check carries a positive control: the non-secret key's unkeyed digest IS found, so a no-hit on the secret rows means something. The no-keyed-digest arm cannot be reached on a stock boot, so that clause is scored from the pin. - **Implicit account linking.** Four rules: no implicit link to an unverified local user; an unlink is honoured; an explicit, signed-in link still works and lifts the refusal; the platform IdP exception holds only on its OAuth path. Grounded in `implicit-account-linking.ts` (`decideImplicitLink`, `IMPLICIT_LINK_REFUSED`, `PLATFORM_IDP_PROVIDER_ID`, `recordUnlinkTombstone`, `refuseImplicitAccountLink`) and the published `sso.mdx` section. The pin is `implicit-account-linking.test.ts`. The item reuses the local OIDC provider recipe from `identity-auth.linked-accounts-social`. The platform-IdP clause and the operator override are pin-scored, and knownGaps says why. - **Share-link password.** The stored hash leaves on no exit (mint, list, redemption). The password is accepted from the `X-Share-Password` header, the query form is still accepted, and the default CORS allow-list carries the header. Both public routes answer `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, and the authenticated routes do not. Grounded in `share-link-service.ts#withoutPasswordHash`, `share-link-routes.ts#SHARE_LINK_PUBLIC_RESPONSE_HEADERS`, the runtime `share-links.ts#PUBLIC_RESPONSE_HEADERS` and `adapter.ts#DEFAULT_CORS_ALLOW_HEADERS`. The pins are the `[objectstack-ai#21839]` blocks in `share-link-password.test.ts`, `share-links-public-cache-headers.test.ts` and the hono-plugin CORS case. Existing clause indices are unchanged. - **Global search.** An unreadable object is never queried, named or counted. An explicit `objects=` naming one answers exactly as a name that matches no object. The object stays refused at its own door. Row scope still narrows a searched object, and a term found only in a field hidden from the caller yields no hit. Grounded in `protocol.ts#searchAll` (the `canReadObject` pre-filter and the `getQueryableFields` narrowing). The pins are the dogfood `search-skip-unreadable.dogfood.test.ts` and the 12 unit cases in `protocol.search-skip-unreadable.test.ts`. The two objectstack-ai#21880 cases have no end-to-end pin yet, and knownGaps says so. The open pinyin-companion finding on objectstack-ai#21880 is recorded as a knownGap with a flag-off instruction, at class level only. The persona reuses the area recipe `qa-contributor-bound-member`. - **Datasource credential matrix.** A2 / A7 (`acceptance[1]` and `acceptance[6]`) are recorded as a known environment gap. They need a reachable credential-protected database of a shipped driver, which no run has had. No recipe is claimed, because none is proven. A successful publish alone may not score them, and the stored-credential half of A7 can be read as a partial reading. Separately, the unknown-driver clause, step 7, its negative and the title now state the ruled boundary from objectstack-ai#21921 and the docs note objectstack-ai#21927. For a plugin driver, only the fixed spellings are redacted (the canonical keys, the former aliases and URL credentials). A non-canonical key served as written is the boundary, not a FAIL. Grounded in `common.zod.ts#CANONICAL_CREDENTIAL_KEYS` and `datasource-credential-redaction.ts#redactableConfigKeys`. ### Re-check 2 evidence (no edit) At the claim ref `9dce635337`: - `cli.scaffold-first-run` (rev 3) step 0 and `cli.scaffold-console-first-paint` (rev 3) step 0 both drop the trailing `npm install` and warn against adding it. Their rev 3 history entries cite objectstack-ai#21845. No other `npm install` step remains in `cli.json`. - `approvals.quorum-m-of-n` (rev 4) `negative[0]` requires a NON-PRIVILEGED repeat actor and names the documented admin override (objectstack-ai#3424) as never a distinctness FAIL. ## Remaining on objectstack-ai#21932 (held, not in this PR) - The objectstack-ai#21864 row (public-form withdrawal layering). Its PR is still open. - The objectstack-ai#21928 row (run-state trigger record mask). That PR adds its own item in `automation.json`. objectstack-ai#21932 remains open for these two rows. ## Validation (at `a72b827e43`) - `pnpm check:platform-checklist`: exit 0. It reports 15 areas and 273 items (269 active, 2 planned). The baseline was 270. Symbol anchors resolve 674 of 684 (baseline 657 of 667): all 17 new anchors resolve, and the objectstack-ai#16898 residual is unchanged at 10. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 13 commands, and all 13 exit 0. `check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` were not built). After building them it exited 0. `--ran` reconciliation: 13 derived, 13 run, 0 unrun. - No package source changed, so there is no package build, test or typecheck. No changeset: `docs/qa/**` publishes nothing. ## Acceptance notes - Source citations name test cases and symbols, never line numbers, because `check:platform-checklist` refuses a `file:line` pin. - `content/docs/data-modeling/drivers.mdx` says a plugin driver's `config` is "stored and served to administrators as written". The read redactor still withholds the canonical spellings (`password`, `authToken`), the former aliases and URL credentials for such a driver (`redactableConfigKeys`). So the docs sentence is slightly broader than the code, and the code is the more protective of the two. The checklist follows the code. This is noted only, with no card. Carrier: none. - A run of `search.global-search-skips-unreadable` picks the walled object and the hidden-field value on the live boot, behind premise guards. The item names likely candidates and does not assume them. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ Co-authored-by: Claude <noreply@anthropic.com>
…es carry the explicit system opt-in (objectstack-ai#21938) Fixes objectstack-ai#21911 Clause-②: no - Each producer takes the explicit system opt-in that exists today. No gate before the hand-off fires on any of them, so nothing accepted or refused changes today. This is a slice of objectstack-ai#21908: the engine-lane producers of the principal-less hand-off. objectstack-ai#21908 stays open, and it builds the deny itself once every producer has a route. ## What changed Every engine call in the card's functions now passes `context: { isSystem: true }`. Inside a `SysMetadataRepository` transaction it passes `{ ...ctx, isSystem: true }`, so the transaction handle still rides along. This is the opt-in that already exists. There is no new API, no export change, and no change to what any door authorizes. | Row | Package | Function (engine calls moved) | |:--|:--|:--| | 1 | metadata-protocol | `findServedOverlayRow` (1 `findOne`) | | 2 | metadata-protocol | `overlayLockLayerAt` (1 `find`, in its store reader) | | 3 | metadata-protocol | `readActiveOverlayRows` / `queryByOrg` (2 `find`), `readFlattenedMetaItems` (2 `find`, draft preview) | | 4 | metadata-protocol | `foldStoredCollection` (1 `find`). These are the only reads `assertRuntimeAuthoringRules` issues. | | 5 | metadata-protocol | `SysMetadataRepository`: `get` 1, `put` 6, `delete` 3, `promoteDraft` 1, `restoreVersion` 2, `listDrafts` 1, `nextItemVersion` 1, `nextEventSeq` 1 | | 6 | metadata-protocol | `recordMetadataAudit` (insert), `persistPackageCommitRow` (insert), `publishPackageDrafts`, `resolveOverlayPackageBinding`, `storedFlowBindingAgrees`, `deletePackage`, `duplicatePackage` (1 read each), `reassignOrphanedMetadata` (`find` + `update`) | | 9 | objectql | `ObjectQLPlugin.readAuthoredActionRows` (3 `find`), `readAuthoredHookRows` (2 `find`) | | 10 | core | `readAuthoredTranslationLayer` (2 `find`) | H1 holds: every row sits where the card says on `cab63967`. Every engine call in each named function was enumerated with the TypeScript AST, not only the first one: 32 in metadata-protocol, 5 in objectql and 2 in core. Each now carries the opt-in. ## The six gates: none fires on these calls (Zone 1) A system context skips the six gates the middleware still runs before the hand-off's `next()`. Each one, on the `sys_metadata` family (`sys_metadata`, `_history`, `_audit`, `_commit`): - **package-managed**: acts only on `sys_permission_set`. - **system-row**: acts only on `sys_position` and `sys_capability`. - **curated-capability**: acts only on `sys_capability`. - **audience-anchor**: acts only on `sys_position_permission_set`. - **engine-owned**: the bucket matches (the family is `engine-owned` / `append-only`), but `isUserContextWrite` needs a `userId`. A context with no principal passes it by construction, exactly as a system one does. - **delegated-administration**: acts only on the RBAC link tables and `sys_member`. **Measured.** A local, uncommitted instrument sat at plugin-security's engine middleware. It wrapped each of the six gates, so a throw was recorded per gate and per operation. It also recorded the outcome after the hand-off. After the change it dry-ran the six gates for every moved `isSystem` call, with the flag cleared. The run covered the dogfood suite and a booted showcase dev composition. - Before: 0 gate throws on any of 35,248 (dogfood) + 388 (boot) principal-less operations, from any producer. 0 downstream failures on the card's functions. - After: 0 gates would fire on any moved call. The instrument was reverted, and `security-plugin.ts` equals its HEAD blob (`5b4ab280`). plugin-security's `dist/` was rebuilt clean, and `ablation-dist-preflight --absent` passed. ## Before and after, per function (H2) Principal-less, non-system operations credited to each function. A function is credited when it is the first frame past the engine, its closures and the repository transaction wrapper. | Function | dogfood before → after | boot before → after | |:--|--:|--:| | `findServedOverlayRow` | 13,614 → 0 | 80 → 0 | | `overlayLockLayerAt` | 13,736 → 0 | 80 → 0 | | `queryByOrg` (`readActiveOverlayRows`) | 2,037 → 0 | 16 → 0 | | `readFlattenedMetaItems` draft preview | 0 → 0 (no run reached it; unit-pinned) | 0 → 0 | | `foldStoredCollection` | 761 → 0 | 0 → 0 | | `SysMetadataRepository.get` / `put` / `delete` | 169 / 296 / 41 → 0 | 0 | | `promoteDraft` / `restoreVersion` / `listDrafts` | 6 / 2 / 1 → 0 | 0 | | `nextItemVersion` / `nextEventSeq` | 105 / 105 → 0 | 0 | | `recordMetadataAudit` / `persistPackageCommitRow` | 110 / 1 → 0 | 0 | | `publishPackageDrafts` / `resolveOverlayPackageBinding` / `storedFlowBindingAgrees` | 1 / 1 / 6 → 0 | 0 | | `deletePackage` / `duplicatePackage` / `reassignOrphanedMetadata` | 1 / 1 / 2 → 0 | 0 | | `readAuthoredActionRows` / `readAuthoredHookRows` | 810 / 496 → 0 | 3 / 2 → 0 | | `readAuthoredTranslationLayer` | 496 → 0 | 2 → 0 | | **all principal-less operations, any producer** | **35,248 → 2,476** | **388 → 204** | After the change, the same calls arrive as `isSystem` operations in matching numbers. For example: `findServedOverlayRow` 13,618, `queryByOrg` 2,037, `put` 296, `recordMetadataAudit` 110. The dogfood suite was green on both sides with identical counts: 205 files passed + 1 skipped, 1,590 tests passed + 9 skipped. The boot answered the same statuses on both sides: admin data reads 200, anonymous reads 401. The 2,476 / 204 operations that remain come from the other slices' producers (settings, messaging, storage, auth, webhooks, datasource). ## Tests - **Unit pins, one per package (H4):** - `metadata-protocol/src/protocol.platform-store-system-opt-in.test.ts`: the engine double records the context of every call. It drives draft save → publish → active save → rollback → delete, the overlay and list reads (each private reader directly, too), and reassign / duplicate / uninstall. Each step asserts that it reached the store and that every call carried `isSystem: true`. Inside the transaction the context is exactly `{ transaction, isSystem: true }`. - objectql: `plugin-authored-actions.test.ts` and `plugin-authored-hooks.test.ts` each gain one case. - core: `authored-translation-sync.test.ts` gains one case. - **Ablations, each committed first and restored through `scripts/ablation-replace.mjs` (blob equals HEAD, `git diff HEAD` empty).** Each pin resolves its subject from `src`, so no `dist` leg was needed. The expected direction was red, and red is what was observed: 1. `findServedOverlayRow` opt-in dropped: 2 of 3 metadata-protocol cases red. 2. `put`'s history-insert opt-in reverted to `{ context: ctx }`: 2 of 3 red. 3. `readAuthoredHookRows`' first read set to `isSystem: false`: 1 of 11 red. 4. `readAuthoredTranslationLayer`'s first read set to `isSystem: false`: 1 of 6 red. - The first attempt at 3 and 4 used a replacement that was a prefix of its anchor. The tool refused it as a no-op and nothing ran. They were re-run with the `false` spelling. - **Package suites (H4 falsified test-side; see the acceptance notes)**, at `89ced04af3`. The merge to `9fd7113eaa` brought only two docs pages and one `rest` test: - metadata-protocol: 217 files passed + 3 skipped, 27,921 tests passed. - objectql (`local` + `repo`): 376 files, 7,476 tests passed. - core: 80 files, 2,226 tests passed. - `typecheck` for all three exit 0, including objectql's and core's `check:test-typecheck`. - **Instrumented runs:** the dogfood suite (7 chunks, 206 files) and a booted showcase dev composition, before and after, all under `os-verify-lock`. The numbers are in the table above. - **Gates:** `dispatch-gates --commands` at `9fd7113eaa` derives 76 families. All 76 were run there and exited 0, and `--ran` reconciles 76 derived, 76 run, 0 NOT-MEASURED. - `check:engine-split-ratio` first refused on the shallow clone. It was deepened (`--shallow-since=2026-06-30`) and re-run. - `check:dual-build-cjs-loads` first needed dists of unbuilt packages and a `plugin-audit` declaration. These were built, and the gate re-ran green. - `check:objectql-double-limit` flagged the new double as limit-blind. The double now applies the caller's bound. - **Lint, a proven narrowing (CI runs the full `pnpm lint`):** 1. The population comes from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` plus the `packages/**` object. 2. `eslint --no-inline-config --format json` over the 13 changed `.ts` files: 13 files, 0 errors, 0 warnings. 3. The config never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot move any untouched file's verdict. ## Acceptance notes - **Same family, not moved here (static, not in the card's list):** `SysMetadataRepository.getByHash`, `list`, `history` and `replayFromHistory` still reach the engine with no context. No measured run reached them (0 records in either probe). They belong to objectstack-ai#21908's closure census. - **One engine check besides the six gates also stands down under `isSystem`:** the referential-integrity check on a caller-supplied lookup. On these writes the only lookup it judged was `sys_metadata.organization_id`, which the repository fills from the door-derived organization. The probe recorded 0 refusals from it (0 downstream failures on the card's functions). The other `isSystem` reads on the census page touch none of these four objects, or only change a log line (the tenant-audit warning, the reference-cleanup actor label). - **H4 was falsified, and the fix is test-side only:** objectql's protocol suites held seven exact-argument expectations, the reassign rebind and the `listDrafts` WHERE. Each now includes the opt-in. Two revert/rollback conflict pins (`protocol-commit-history`, `protocol-writepath-object-ownership`) found `put`'s in-transaction read by a bare `context` key, and every repository read now carries one. Their engine now hands its transaction callback a handle, as `ObjectQL.transaction` does, and the pin discriminates on that handle. metadata-protocol's and core's own suites passed unchanged. - **`scripts/engine-double-contract.pinned.json`** gains three rows (`--write`, a grow-only coverage ledger) for the new pin's double. That double is copied from the pinned one in `protocol-publish-drafts-org-scope.test.ts`. - **Probe artifact:** after the change, the `isSystem` count for `overlayLockLayerAt` reads 10. This is not because its reads vanished. The function is not `async`, so it does not appear in the async stack the probe filtered system records by. Its principal-less count (the claim) is 0 on both runs. - **H5:** objectstack-ai#21864's head (`d8657b5c`, re-tested after every merge of `main`) test-merges cleanly onto this branch at `9fd7113eaa`. Its hunks are in `anonymousFormIntakeOrgScopeRefusal`, `saveMetaItem` and `promoteDraftForPublish`, and none of them is a named function here. All 16 protocol calls keep the opt-in in the merged tree. - **H6:** the cross-lane declaration is on objectstack-ai#6367 (`6003826474`). - **H3:** the `isSystem` census page needs no edit. Object-literal producers are not elevation reads, and the census gate is green with its counts unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…curity follow-up) (objectstack-ai#21964) Fixes objectstack-ai#21932 Clause-②: no ## What This PR adds one checklist item, `access-security.public-form-withdrawal-layers`, to `docs/qa/platform-checklist/areas/access-security.json`. It sits right after `access-security.public-form-intake`. Its fields are rev 1, `since: v17.7`, P1, surface `api`. No other file changes. This delivers the last two rows of objectstack-ai#21932. The first five rows and both re-checks landed in PR objectstack-ai#21943. ### The objectstack-ai#21835 / PR objectstack-ai#21864 row: public-form withdrawal layering The item is written against what PR objectstack-ai#21864 landed on `main`. Its merge, `3c7785d4ab`, is an ancestor of this branch's base `01e0f71a`. Each rule on the card maps to a clause: | Card rule | Where in the item | Oracle | Code anchor | |---|---|---|---| | An env-wide withdrawal is not re-opened by an org overlay | acceptance[0]: both doors answer 404 `FORM_NOT_FOUND` and no row lands. acceptance[1]: an org-scoped save that would leave the form open answers 403 `NOT_OVERRIDABLE` | api | `rest-server.ts#registerFormEndpoints`, `anonymous-form-intake.ts#anonymousFormIntakeWithdrawnIn`, `protocol.ts#anonymousFormIntakeReopenRefusal` | | Only an explicit false withdraws | acceptance[2]: with an absent `allowAnonymous`, or no `publicLink`, env-wide, the org save that opens the form is accepted and both doors serve it | api | `anonymous-form-intake.ts#anonymousFormExplicitWithdrawals`. Premise: `protocol.ts#projectStorableViewBody` | | A package's shipped false withdraws | acceptance[4] | test | `anonymousFormExplicitWithdrawals`. Pins: `protocol.org-scoped-write-refused.test.ts` ('single: a package-shipped form') and `anonymous-form-intake.test.ts` | | The env-wide definition may open a package-closed form | acceptance[5] | test | `protocol.ts#envWideRawViewRows`, with the same protocol pin | | The ruled known limit is recorded as a known gap | `fixtures.knownGaps[0]`, plus a negative saying it is not a FAIL | none | The doors match by served item name. The save check runs only from `saveMetaItem` and the draft promotion, never from `rollbackMetaItem` or `revertCommit` | acceptance[3] is the control pair, which the dogfood also pins: - An organization can always withdraw the form for itself. - A form open at both layers is served, and its row lands in the organization. `automated.ref` leads with `packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts`. That dogfood covers acceptance[0], [1] and [3] end to end. The ref also names the rest, metadata-protocol and metadata-core unit pins. The steps drive the stock showcase form, `showcase_inquiry.contact` at `/forms/contact-us`. The admin saves it at two scopes: env-wide, and in the Default Organization the doors read. Both doors are probed anonymously. ### Where the code is narrower than the card's wording Where they differ, the item follows the code: - **A package's shipped false.** This holds only for an artifact the stack schema parsed (strict `defineStack`, the default). There the schema default `enabled: false` counts as an explicit false. An artifact loaded unparsed (`strict: false`, or a hand-built manifest) is judged as written, so a switch it omits is absent and withdraws nothing. acceptance[4] says so. - **Only an explicit false.** The false must sit on a sharing that keeps a non-empty `publicLink`. A sharing with no link withdraws nothing, even with both switches false. acceptance[2] says so. - **A second documented limit.** `main` carries "Known limit: packages and names" besides the ruled one. Cases where two packages ship the same view name are outside this item's fixture. The item points at that docs section as it reads at the run's commit, rather than restating it. ### The objectstack-ai#21867 / PR objectstack-ai#21928 row Confirmed on `main` with no change. The item is `automation.paused-run-trigger-record-masked` in `docs/qa/platform-checklist/areas/automation.json`, at rev 1, `status: active`. Its `automated.ref` is `packages/qa/dogfood/test/flow-trigger-record-credential-mask.dogfood.test.ts`, which is on disk. PR objectstack-ai#21928 merged as `1f0469655f`, an ancestor of this branch's base. ### Overlap with objectstack-ai#21934 objectstack-ai#21934 is not addressed here. Its PR objectstack-ai#21962 was an open, unmerged draft when this PR was opened, so the item is written against `main` as it stands. - **The ruled known limit does not depend on objectstack-ai#21934.** PR objectstack-ai#21962 leaves the docs page's "Known limit." paragraph and the doors' name-based identity unchanged. - **The multi-package line holds either way.** PR objectstack-ai#21962 rewrites the "Known limit: packages and names" section. This item points at that section as it reads at the run's commit and names objectstack-ai#21934, so its line stays true whether or not PR objectstack-ai#21962 lands. - **The `envWideRawViewRows` note holds either way.** It is scoped to "a form one package ships", which is true before and after PR objectstack-ai#21962. That PR keeps the symbol and resolves it per package. - **No shared files.** This PR touches only the checklist JSON. It changes neither `content/docs/ui/public-data-collection.mdx` nor any package source. ## Tests All results are at head `2d51effa`. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 13 commands, the same 13 the dispatch named. All 13 exited 0, with each exit code captured before any pipe. The `--ran` reconciliation reads 13 derived, 13 run, 0 NOT-MEASURED, 0 UNRUN. - **Checklist gate.** `pnpm check:platform-checklist` answered `OK — 15 areas, 275 items (271 active, 2 planned)` with 690/700 symbol anchors resolved. At the base `01e0f71a` it read 274 items and 676/686. All 14 new anchors resolve, and the 10 that do not are the named objectstack-ai#16898 residual. - **Formula gate.** `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited 3 (PREREQUISITE NOT MET, because formula and lint were unbuilt), so that run measured nothing. I built both under `os-verify-lock` (VERDICT command-exit 0), and the gate then exited 0. - **Not owed.** No package source changed, so no build, test, typecheck or lint is owed. The cited test-case names were read from the test files on `main`. The pins themselves were not re-run here; they ran in CI on PR objectstack-ai#21864 and PR objectstack-ai#21928. ## Acceptance notes - **`coverage.json` is untouched.** The claim's file surface is `areas/*.json`, and the `view` kind is already mapped. Mapping the new item to `view` is optional, and is left to whoever next owns `coverage.json`. - **A stale clause in the sibling item.** `access-security.public-form-intake` clause 7 says "republishing restores service" but does not name the scope of the republish. With layering, republishing in an organization over an env-wide withdrawal is refused with a 403. The new item covers that case. The old item is unchanged, with no revision bump, to keep this PR to the card's rows. - **No changeset.** The diff touches only `docs/qa/platform-checklist/areas/access-security.json`, which no published package ships: the root package is private, and no package `files` entry names `docs/qa`. `skip-changeset` applies. --- _Generated by [Claude Code](https://claude.ai/code/session_01VF48aw8RPG6wzDnMgp6rtw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…w-ups (package identity, judged draft, lock key, row anchor) (objectstack-ai#21962) Fixes objectstack-ai#21934 Clause-②: yes (widening) Four LOW/INFO follow-ups to the public-form withdrawal work of objectstack-ai#21864, one commit and one pin each, so any item can be dropped at review without the others. Each change is described in the card's public terms. All four land in `@objectstack/metadata-protocol`; the only other source edit is a docblock in `@objectstack/metadata-core`, plus the narrowed sentence on the public data collection docs page. ## Item 1: package identity of a served org overlay (`21f75eb892`) **Measured.** The judgement the anonymous form doors and the organization-scoped save check share (`anonymousFormIntakeWithdrawnIn`, `packages/metadata-core/src/anonymous-form-intake.ts:329`) compares no package, and the doors' lookup (`findPublicFormView`, `packages/rest/src/rest-server.ts:10735`) reads no `_packageId`. So the package stamp the list merge puts on a package-less org overlay (`packages/metadata-protocol/src/protocol.ts:2166` and `:9077`) cannot by itself make a withdrawal miss it. The item's outcome was still reachable on `main` (`a3bd157730`), through the same list merge rather than through a package comparison: the env-wide view list the doors judge against could hold only one package's item of a view name that two packages ship. Measured through the protocol's real list reads and the doors' own verdict: an overlay stored package-less before the withdrawal stayed open after one package's withdrawal and closed after the other's. **Changed.** `protocol.ts`, the list merge's view branch: only a name a stored view container's expansion writes is upserted by name. Every other name keeps one item per package that ships it (ADR-0048), as the list already served it while no view row was stored. Neither of the card's two directions applies (nothing compares packages, so marking stamped copies or reading the org row's own `package_id` changes no verdict); the fix is at the producer of the layer the doors read. No door code changes. **Pin** (`protocol.org-scoped-write-refused.test.ts`, "a package-less organization overlay, two packages shipping its view name"): the organization read serves the overlay once per package, each copy stamped with that package; for the package first and the package second in registry order, after it withdraws the name env-wide the env-wide list holds the withdrawal beside the other package's body, the doors serve no copy of the overlay, and a re-save of the overlay is refused. ## Item 2: the publish gate and the promotion are separate reads (`d1365db627`) **Measured** (H2 confirmed). `promoteDraftForPublish` reads the draft through `repo.get` to judge it (`protocol.ts:21623` at base), and `SysMetadataRepository.promoteDraft` reads the draft row again with its own `findOne` (`sys-metadata-repository.ts:969` at base). Nothing tied the two reads together, so a draft saved between them, or a draft that appeared where the gate found none, was promoted without being judged. **Changed.** A publish promotes only the draft its gate judged. `SysMetadataRepository.promoteDraft` takes an optional `expectedDraftHash` (`string | null`): when stated, the draft row it reads must carry that hash (with `null`, no draft row may exist), otherwise it throws a `ConflictError` subclass before anything is written. `promoteDraftForPublish` passes the judged draft's hash (or `null`), and answers the conflict as `409 METADATA_CONFLICT` with its own wording (publish again to judge and promote the current draft). This covers `publishMetaItem` and each promotion of `publishPackageDrafts`. A route without a new public option exists and was not taken: the existing `deriveActiveBody` callback receives the body the promotion read and could compare it with the judged body and throw. It turns a derivation hook into a guard and compares bodies instead of the stored hash the card's direction names, so the explicit option was preferred. That option is the Clause-② widening below. **Pin** ("a publish promotes only the draft its gate judged"): a draft saved after the gate read, and a draft saved where the gate judged none, are not promoted and the conflict answers; control: with no save in between, the judged draft is promoted and its draft row drained. ## Item 3: the lock lookup uses the request's package (`114ed6393c`, follow-up `7c30229b43`) **Measured** (H3 confirmed). The publish path passed `request.packageId` to `lockWriteRefusal` (`protocol.ts:21583` at base), while the gate resolves its draft key a few lines later: the stated binding, else the resolved draft row's own `package_id` (`draftKey`). Since the lock resolution reads every row and every shipping package in scope and takes the strictest lock, the package in the address decides whose lock prose the refusal carries, not whether it refuses: the INFO grade. **Changed.** The draft key is resolved before the lock check and threaded into the lock lookup. The authoring-rule narrowing to the stated package is left exactly as it is. Follow-up `7c30229b43`: with the draft-key read moved above the lock check, a store that cannot be read is answered at that read as the lock read answered it before (an unprovisioned `sys_metadata` holds no draft; any other failure is `503 SERVICE_UNAVAILABLE`, never the driver's own error). **Pin** ("a publish consults the lock of the package key it resolved"): with two packages' env-wide rows of one view both locked, a publish that states no package is refused with the lock of the draft row's own package; control: stating a package consults that package's lock. Follow-up pin ("a publish that states no package, over a store that cannot be read"): it answers 503 and promotes nothing. ## Item 4: the save check's row anchor across packages (`1e271aaae1`) **Measured** (H4 confirmed). `envWideRawViewRows` (`protocol.ts:16092` at base) returned every stored env-wide row of the name when any existed (so one package's row hid every package's artifact), and otherwise fell back to `lookupArtifactItem(type, name)` with no package key (the first package in registry order). **Changed.** The save check anchors each package's row on that package's env-wide definition: the package's own env-wide row, else the package-less env-wide row (which stands in for every package, as in the list merge), else that package's artifact, read through `shippedArtifactsOf`. **Wording.** The "never under-closes" sentence is narrowed in the `anonymousFormIntakeWithdrawnIn` docblock and in the "Known limit: packages and names" paragraph of `content/docs/ui/public-data-collection.mdx` (declared to `domain:devx` on objectstack-ai#6023). The released changeset of objectstack-ai#21864 is not edited; this card's changeset states the narrowing. As corrected in `3eea8f0995` after the contract review, the narrowed text keeps "a withdrawal of a view name still closes that name in every package, so it may over-close" and the statement that the organization-scoped save check judges every package's environment-wide definition of the name, and states the endpoints' one exception: where a package's environment-wide copy of a view container is saved, the endpoints read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. Reading each package's expansion separately is tracked in objectstack-ai#21967. The narrowed text assumes items 1 and 4 both land; if item 1 is dropped, the endpoint exception in that paragraph widens to every view name two packages ship. **Pin** ("the save check anchors each package's row on that package's env-wide definition"): with the withdrawing package not first in registry order, a package-less and a package-bound org save that renames the form are refused; another package's env-wide row anchors that package only; controls: the save that keeps the form withdrawn saves, and a package-less env-wide row stands in for every package. ## Clause-② Measured against the built entry declarations, base `a3bd157730` against head `5297072f13`, comments stripped before the diff: - `@objectstack/metadata-protocol` `dist/index.d.ts`: `SysMetadataRepository.promoteDraft(ref: MetaRef, opts: {...})` gains `expectedDraftHash?: string | null;` (head line 9883). An optional input field: a widening. The only other declaration difference is comment placement. - `@objectstack/metadata-core` `dist/index.d.ts`: the declaration of `anonymousFormIntakeWithdrawnIn` (parameters `layer`, `view`, `candidate`, returning `boolean`) is byte-identical (base line 21311, head line 21316); only its docblock changed. Unchanged at the final head `3eea8f0995`: the later commits change a method body, a docblock, the docs page and a changeset, and the rebuilt declarations are identical with comments stripped. So `Clause-②: yes (widening)`, and item 2's changeset is `minor`. The other three changesets are `patch`. `@objectstack/metadata-core` carries no changeset: its edit is a comment. ## Tests Final head `3eea8f0995` (`origin/main` `76fec88b16` merged at `5297072f13`). The last commit, `3eea8f0995`, corrects wording only (the docs page, one changeset, a docblock); `packages/metadata-protocol/src` is byte-identical at `7c30229b43`, where its suites ran: - `@objectstack/metadata-protocol` at `7c30229b43`: typecheck green (`tsc --noEmit`; the edited test file is in the program, counted with `--listFiles`), full suite 218 files passed, 3 skipped; 27984 tests passed, 19 skipped. - `@objectstack/metadata-core` at `3eea8f0995`: typecheck green (both programs); 18 files, 411 tests passed. - `@objectstack/objectql` (a consumer of the protocol, against its `dist` built at `5297072f13`; the later code commit only changes an outage path): 378 files, 7507 tests passed. - Reverse verification through `scripts/ablation-replace.mjs`, each from a committed head, each item's code set back to its base shape (anchor hit once, blob changed on disk; the test imports the source, so no `dist` leg), the item's pin run, then restored and proved (blob equal to HEAD, `git diff HEAD` empty): - from `1e271aaae1` (its `protocol.ts` blob is the one at `5297072f13`): item 1: 5 red, 2 green (the two write-door re-save cases, which item 1 does not touch); item 2: 2 red, 1 green (the control); item 3: 1 red, 1 green (the control); item 4: 3 red, 2 green (the two controls); - from `7c30229b43`: item 3's follow-up, its store-failure classification removed: its pin 1 red. - Gates at `3eea8f0995`, derived by `node scripts/pm/dispatch-gates.mjs --commands` (no paths; the same 93 commands as at `5297072f13` and `7c30229b43`): 92 run green, among them `check:doc-authoring`, `check:docs-audit-scope`, the docs-audit `check-affected-docs` and `check-drift-comment`, `check:docs`, `check:nul-bytes`, and the changeset gates (`check-changeset-no-major` with this PR's payload, `check-empty-changeset`, `check-adr-0087-registration`, `check:changeset-gate-self-tests`). 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` (PREREQUISITE NOT MET: it loads every workspace package's `dist`, 32 of which were not built in this worktree; it was green at `7c30229b43`, whose code this head keeps). Reconciled: `dispatch-gates --ran` answers "93 derived famil(ies) accounted for — 92 run, 1 NOT-MEASURED". The artifact-roster block (53) is green, the PR-context gates run against this PR. The four symbol-anchor sweeps (`check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`, `check:adr-anchors`) are green. ## Acceptance notes - Residual of item 1, stated in the docs: the list still upserts a stored view container's expansion by name, so where a package's environment-wide copy of a view container is saved, the anonymous doors read that copy's expansion alone for each form it expands, and can miss another package's withdrawal of that form, whether saved or shipped. The organization-scoped save check still judges every package's environment-wide definition of the name (item 4). Keeping each package's expansion apart changes the expansion rules the list and the by-name read share; that design is tracked in objectstack-ai#21967. - No door code changes, so no door-level dogfood case was added (declared to `domain:cli` on objectstack-ai#6024). - The new conflict subclass is internal to `@objectstack/metadata-protocol` (not exported from its entry); callers see a `ConflictError`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21835
Clause-②: yes (widening)
Fixes a regression introduced after 17.6.0 (with #21420); it should land before 17.7.0 is cut.
What
Per the rulings recorded on #21835: a public form's withdrawal is a kill switch, layering can only narrow anonymous intake, a withdrawal closes the same form only, and only an explicit withdrawal counts.
GET /forms/:slug,POST /forms/:slug/submit). Both use one resolver and judge by the name of the view item they serve. When an organization is resolved, the env-wide view list beneath it is read as well. A form is served only when the env-wide item of the same name does not explicitly withdraw a form in the same slot (nested form, the sameformViewskey, or the flattened config) or with the same slug. Other views that share the public slug never close each other.publicLinkand setsenabled: falseorallowAnonymous: false. Only an explicit false counts. Not a withdrawal: an absent switch, a sharing with no link (raw, or schema-parsed), a cleared link, a removed sharing block, or no body of the view at that layer. The public data collection docs page has a "Withdraw a public form" section with these rules.viewsave or draft promotion in the organization the doors read is refused with403 NOT_OVERRIDABLEwhen it would leave open a form the env-wide definition explicitly withdraws. It judges by the stored row: the body is compared with the env-wide body of the row it is keyed by (the active env-wide row, else the package artifact), matched by slot or by slug. So renamedformViewskeys,form.name, slot moves and listViews collision renames are the same form. It is also judged against the env-wide view list the way the doors read it, with container bodies expanded. Re-saving an overlay that was open before the withdrawal is refused. The message names both remedies.defineStack, the default) carries the schema's defaultenabled: false, so a shipped form that keeps its link without switchingenabledon is an explicit withdrawal and fails closed. An artifact loaded without that parse (defineStack(..., { strict: false })or a hand-built manifest) is judged as written: a switch it omits is absent, which is not a withdrawal. The env-wide definition is the administrator's switch, so an env-wide save may open a form the package ships closed.@objectstack/metadata-coreadds one export,anonymousFormIntakeWithdrawnIn(minor).@objectstack/restand@objectstack/metadata-protocolarepatch.Tests
The first bullet is round 6, the second round 5, the third round 4; the bullets after them were measured at
e8778acb96(round 2):7882eef683(merged origin/main9dce635337, merge commit46d08189a7): metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27940 passed, 19 skipped; rest 260 files, 4912 passed, 326 skipped; objectql 375 files, 7469 passed (suites at4d5f6c4e61; the later commits touch docs, the changeset and three ported dogfood files only). Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green at7882eef683, reconciled withdispatch-gates --ran;dispatch-gates --self-test1976 cases pass. The cross-package skip of round 5 is removed per the ruling, so a withdrawal of a view name closes it in every package again. Pins: another package's withdrawal of the same name closes this package's form too (metadata-core and the doors); with two packages shipping the same view name, a row-anchored rename by a package-bound org save is refused (metadata-protocol), with a withdrawn-save control. Ablation: the two edited sources set back to their round-5 blobs and rebuilt, markers proved indist/: 1 red in each of metadata-protocol, metadata-core and rest; restored to HEAD (git diff HEADempty), rebuilt, markers proved absent.d8657b5c19: metadata-core 18 files, 411 passed; metadata-protocol 216 files (3 skipped), 27938 passed, 19 skipped; rest 260 files, 4911 passed, 326 skipped; objectql 374 files, 7464 passed. Typecheck green for metadata-core, metadata-protocol, rest and objectql, test layers included. 97 of 97 derived gates green, reconciled withdispatch-gates --ran. New pins: two packages' drafts of one view in one organization are each judged on their own publish; one package's withdrawal of a name closes its own form (metadata-core and both doors; the cross-package half was inverted in round 6). Ablation: removing the package key from the publish gate's draft read turned the two-package pin red, and removing the package comparison turned the cross-package pin red; both restored to HEAD (git diff HEADempty).79b847042d(targeted): metadata-coreanonymous-form-intake.test.ts39/39, metadata-protocolprotocol.org-scoped-write-refused.test.ts41/41, restpublic-form-withdrawal+public-form-intake-availability43/43. Typecheck green for metadata-core and metadata-protocol. Docs and changeset gates green. New pins: a package parsedfalseis a withdrawal; an env-wide save opens a package-closed form.@objectstack/metadata-core: 18 files, 394 passed.@objectstack/rest: 260 files, 4906 passed, 326 skipped.@objectstack/metadata-protocol: 214 files (3 skipped), 27752 passed, 19 skipped.check:skill-examples,check:dual-build-cjs-loadsandcheck:type-check-debtare NOT MEASURED locally (workspace-wide prerequisites) and left to CI.Acceptance notes
strict: false, a hand-built manifest) is judged as written; giving every load path the schema's sharing defaults is left as a possible follow-up (see the round 5 report on security(forms): a public-form setting at one metadata layer can re-open intake that another layer withdrew — 17.7 regression, detail withheld pending maintainer #21835).protocol-publish-package-drafts.test.ts); that is test-only.packages/qa/dogfood/test/per-file-cwd.setup.ts,packages/qa/dogfood/test/per-file-cwd.global-setup.ts,packages/qa/dogfood/vitest.config.ts) so the dispatch-gates self-test is green here; they merge away once test(dogfood): each file's temporary cwd is created from a base the scratch-dir scan can read #21935 lands.Generated by Claude Code