Skip to content

fix(metadata-protocol): refuse an org-scoped public form withdrawal a walled posture cannot honour - #21473

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-21468-walled-form-withdrawal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-21468-walled-form-withdrawal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #21468

Clause-②: no

Withdrawing or publishing a public form on a walled tenancy posture (degraded or not) is now refused loudly at authoring when the save is organization-scoped and the anonymous form doors cannot honour it; pinned both sides. Env-wide saves still withdraw and republish the form on every anonymous door, and single-posture deployments are unchanged.

Base: this branch was cut from the #21331 fix branch while #21420 was queued; #21420 has since merged and origin/main is merged into this branch (296d7342), so the diff against main is exactly the five files below.

What changed

  • packages/metadata-protocol/src/protocol.ts: a new refusal on the metadata save door, anonymousFormIntakeOrgScopeRefusal. It runs in saveMetaItem (active and draft saves) and in the draft-to-active promotion. It applies to an organization-scoped view write on a posture where the anonymous doors cannot honour it (read from the tenancy service), and only when the write changes which slugs the form opens to anonymous intake, compared with the env-wide definition. It answers 403 NOT_OVERRIDABLE with organizationId and docs set. The message says why and gives the remedy: save it env-wide. This reuses the code and status of the sibling org-scope refusal (orgScopedWriteRefusal). There is no new error code and no spec change.
  • packages/metadata-protocol/src/anonymous-form-intake.ts: anonymousFormIntakeSlugs, which projects a view body to the slugs it opens to anonymous intake. It reads the same three candidate shapes and the same allowAnonymous + publicLink predicate as the anonymous doors.
  • Edits to a form that is organization-scoped but leaves its anonymous slug set alone still save. So do env-wide saves, and every save on a single posture, where defaultOrgId() is the admin's organization.

Tests

Run at b5e4b515 (this branch's head when this was written).

  • packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts: a new describe block of 7 cases on the existing stub engine. The walled posture is modelled with defaultOrgId answering null. Four refusal cases: org-scoped withdrawal (envelope, and no org row written), org-scoped draft, org-scoped publish of an env-wide-private form, and promotion of a legacy org-scoped draft. Three controls: an org-scoped edit that leaves the sharing alone, the env-wide withdrawal, and the single posture (defaultOrgId equal to the org). Result: 22/22 pass.
  • packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts: a real boot with multiTenant: 'posture-only' (isolated, defaultOrgId() answers null) of a fixture form bound to an object declared tenancy: { enabled: false }. Precondition: the published form serves and accepts (200 / 201, the row lands). In an organization, the withdrawal answers 403 NOT_OVERRIDABLE and names the env-wide save and nothing is saved. An org-scoped label edit saves (200, org=). An env-wide withdrawal makes both doors answer 404 FORM_NOT_FOUND, and no row lands. An env-wide republish restores both doors. Result: 5/5 pass.
  • The single-posture pins of fix(rest): withdrawing a public form takes effect on every anonymous intake door #21420 (showcase-public-form-withdrawal.dogfood.test.ts) and showcase-public-form.dogfood.test.ts / public-form-read-back-masking.dogfood.test.ts are still green.
  • pnpm --filter @objectstack/metadata-protocol test: 201 files passed, 3 skipped; 2990 tests passed, 19 skipped.
  • typecheck passes for @objectstack/metadata-protocol and @objectstack/dogfood.
  • Ablation, run on the committed head: the guard's first line was mutated so that it always returns null, using scripts/ablation-replace.mjs with a restore trap. Then a rebuild, and ablation-dist-preflight confirmed the marker in 2 dist files. Unit: 4 red / 18 green (the four refusal cases). Dogfood: 1 red / 4 green (the organization-scoped refusal case). Restore: blob equals HEAD, git diff HEAD is empty, rebuilt, marker absent from all 24 dist files.
  • Derived gate families (dispatch-gates.mjs, 70): 68 exit 0. check-plugin-teardown-shape --self-test first exited 3 (shallow clone, pinned fixture unreachable); after fetching that commit it exited 0. check:dual-build-cjs-loads is NOT MEASURED (exit 3, PREREQUISITE NOT MET: unrelated packages have no dist/), declared to CI. pnpm lint was not run locally and is declared to CI.

Acceptance notes

  • With no tenancy service registered, the refusal does not apply. Such a composition has no posture to judge and no session to carry an organization over HTTP.
  • On a walled posture, an anonymous submit into a form bound to a tenant-scoped object answers 500 (ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED from the engine). That is why the walled pin uses a tenancy-disabled fixture object instead of the showcase contact form. This PR does not change it; it is reported to the dispatching seat.

Generated by Claude Code

claude added 10 commits October 2, 2026 13:10
Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-authored-by: Claude <noreply@anthropic.com>
…e the anonymous doors cannot see

Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 8 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), NOT_OVERRIDABLE (literal, a string literal in anonymousFormIntakeOrgScopeRefusal))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/ui/forms.mdx (via /forms/:slug (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, a path literal in a comment on a changed line))
  • content/docs/ui/public-data-collection.mdx (via /forms/:slug (route, a path literal in a comment on a changed line), /forms/:slug/submit (route, a path literal in a comment on a changed line))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via NOT_OVERRIDABLE (literal, a string literal in anonymousFormIntakeOrgScopeRefusal), /forms/:slug (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 72af58c62167d21e5663c099a62f998f72afe81a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 776a90eef4ddcff243442f7645fbe08dbeea204a — the merge of head ece345ca11412ef5d7c95799ef84bf6e95d5d0ac into base 72af58c62167d21e5663c099a62f998f72afe81a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 776a90eef4ddcff243442f7645fbe08dbeea204a && git checkout 776a90eef4ddcff243442f7645fbe08dbeea204a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72af58c62167d21e5663c099a62f998f72afe81a ece345ca11412ef5d7c95799ef84bf6e95d5d0ac && git checkout -B drift-repro 72af58c62167d21e5663c099a62f998f72afe81a && git merge --no-ff ece345ca11412ef5d7c95799ef84bf6e95d5d0ac

node scripts/docs-audit/affected-docs.mjs --json 72af58c62167d21e5663c099a62f998f72afe81a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 72af58c62167d21e5663c099a62f998f72afe81a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/m and removed size/l labels Oct 2, 2026
This was referenced Oct 3, 2026
…lled-form-withdrawal

# Conflicts:
#	packages/metadata-protocol/src/protocol.ts
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 06:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 06:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit ce53218 Oct 3, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21468-walled-form-withdrawal branch October 3, 2026 07:22
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ody whose name disagrees with its row name, for every type, through the one judge (objectstack-ai#21470) (objectstack-ai#21536)

Fixes objectstack-ai#21470
Clause-②: yes (narrowing)

Every runtime door that writes a `sys_metadata` row now refuses a body
whose own `name` disagrees with the name it writes the row under, for
every metadata type, with `VALIDATION_ERROR` / 400, before anything is
stored or registered. The refusal goes through the one judge objectstack-ai#21412
landed (`@objectstack/metadata/view-container-name`). The doors are
`saveMetaItem`, `rollbackMetaItem`, the restore limb of `revertCommit`,
and the draft promotion that `publishMetaItem` and
`publishPackageDrafts` share.

It follows the seat's answer on the card (5964758196: Q1 A on a premise,
Q2 A), the claim's revision 2 (5964548518), and triage's direction
5962080068.

## The judge

- `savedItemNameRefusal(type, item, saveName, door)` replaces
`savedViewContainerNameRefusal(container, saveName)` on the subpath.
`door` is `'save'`, `'restore'` or `'publish'`.
- **The rule is row 1's predicate** (`assertMetadataRegisterContract`):
a `name` the body carries (`!== undefined`) must equal the name the row
is written under. There is one exception, and it belongs to the door,
not the type: the save door stamps a missing view `name` after the judge
runs. So for a `view` at `'save'`, a `name` counts as set only when it
is a non-empty string. That is the container case's behaviour from
objectstack-ai#21412, unchanged.
- **Unchanged:** `viewContainerNameRefusal` (the source registrars'
entry), its words, `objectql`'s re-export, the boot loop and `os
validate`.
- **Published surface.** The subpath has never shipped:
- `npm view @objectstack/metadata@latest exports --json | grep -c
view-container-name` prints `0`. The control: `"./view-container"`
counts `1`, and latest is `17.6.0`.
- `git ls-tree origin/main
.changeset/21412-metadata-view-container-name-judge.md` prints the blob
line `c8df04b2…`.
- Both were re-checked before this push, on `origin/main` `c98a72d69e`.
- So no published export is removed. The PR's line reads `Clause-②: yes
(narrowing)` because the subpath's export set changes against `main`.

### The container case is byte for byte unchanged

I rendered the save-door words for P1, P3 and P4 and the derived entry's
words for P1, before the change (`savedViewContainerNameRefusal`) and
after it (`savedItemNameRefusal('view', …, 'save')`), each from the
built `dist`. Both renders give the same sha256,
`0f65c121514e148caae28f82f03b64db2fcd21c04b561ccb75f73af4bb629352`, and
`cmp` reports them identical. The control: after the build, the `dist`
has 0 hits for the old name and 2 for the new one.

### The words for every other body and door (rendered from `dist`)

```text
Invalid dashboard: its own `name` is 'dash_b', which disagrees with the name it is saved under, 'dash_a'. A disagreement is almost always an authoring bug, and resolving it silently in either direction can file the item under a key the caller never wrote (refuse loudly, locate the mismatch). Register under one name: set `name` to 'dash_a', or save the item under 'dash_b'.
Invalid view: its own `name` is 'crm_lead.other', which disagrees with the name it is saved under, 'crm_lead.mine'. … Register under one name: drop `name`, or set it to 'crm_lead.mine'.
Invalid field: its own `name` is 'zz_probe', which disagrees with the name it is saved under, 'crm_task.zz_probe'. … Register under one name: drop `name` (a `field` row is named object.field, which its column `name` cannot spell).
Invalid dashboard version: its own `name` is 'dash_b', which disagrees with the name it is restored under, 'dash_a'. … Register under one name: save the item with `name` set to 'dash_a', or under 'dash_b', instead of restoring this version.
Invalid dashboard draft: its own `name` is 'dash_b', which disagrees with the name it is published under, 'dash_a'. … Register under one name: save the draft again with `name` set to 'dash_a', or under 'dash_b', then publish it.
```

Each remedy is true for its type and its door:

- **`drop name`** is offered only where dropping works: a view (the save
door stamps a missing name), and a `field`. `FieldSchema` does not
require `name`, and its `name` is dot-free, so it can never equal an
`object.field` row name.
- **`set name`** is offered for every other type, together with the
opposite direction: save the item under its own `name`. A save name the
type's schema cannot spell leaves only that direction. Measured: 22 of
the 27 schema'd registry types refuse a dotted body `name`, and the save
door's grammar admits dotted row names.
- **At the restore and publish doors**, the remedy is the save that
fixes the stored body, because their caller cannot edit a stored version
or draft in place.

## Callers in `protocol.ts`

- **`saveMetaItem`**: the existing call site, now for every type, still
before `normalizeViewMetadata`. ⛔ PR objectstack-ai#21473's public-form lines are not
touched; its nearest hunk sits about 230 lines above.
- **`rollbackMetaItem` and `revertCommit`'s restore limb**: through a
new private `restoredBodyWriter(type, name)`. It is the
`deriveRestoredBody` that `repo.restoreVersion` calls on the very
history body it read, before it reads the active row and before `put`.
It runs the judge first and then the existing credential-channel strip
(objectstack-ai#20790 R2). A refused version writes nothing:
  - `rollbackMetaItem` rethrows the refusal;
  - `revertCommit` reports `failed[]` with `code: 'VALIDATION_ERROR'`.
- **`promoteDraftForPublish`**: judges the `draftForGate` body before
`repo.promoteDraft` writes, beside the existing authoring gate and in
the same way. ⛔ PR objectstack-ai#21473's promotion gate inside `publishMetaItem`
(about `:19410`) is not touched.

## Census of at-rest rows (triage step 1)

Taken on `objectstack-ai/objectstack` at `e9dec3dab`. Each bootable
example booted with `--fresh` and its seeds. Every `sys_metadata` and
`sys_metadata_history` row was read straight from the fresh SQLite file
(read-only), and each body's `name` was compared with its row's `name`.

| app | boot | seeds | `sys_metadata` rows | body name differs |
`sys_metadata_history` rows | body name differs |
|---|---|---|---|---|---|---|
| app-crm | `pnpm dev:crm -- --fresh` | 28 | 0 | 0 | 0 | 0 |
| app-todo | `pnpm dev:todo -- --fresh` | 8 | 0 | 0 | 0 | 0 |
| app-showcase | `pnpm dev -- --fresh` | 132 | 0 | 0 | 0 | 0 |
| app-multi-package | `pnpm --filter @objectstack/example-multi-package
dev -- --fresh` (no root script) | none printed | 0 | 0 | 0 | 0 |
| embed-objectql | not bootable (a vitest demo) | n/a | no
`sys_metadata` table: no metadata protocol in its closure | n/a | n/a |
n/a |
| hosted tenant | **NOT MEASURED**: no cloud access in this session | |
| | | |

- **Control (the reader sees WAL-resident data):** `sys_user` reads 1 /
1 / 3 / 1 and `sys_permission_set` reads 10 / 8 / 17 / 8 in the same
four files.
- **Step 3 needs no conversion on this corpus.** The census finds 0
rows, so on the measured corpus there is nothing to convert first.
- **A stored row stays readable.** A row stored before this change keeps
its bytes. The write doors now refuse to re-write it: a
`migrateStoredMetadata` pass reports it `failed`, and a rollback, revert
or publish of it is refused with the remedy.

## Measured before the change (`origin/main` `e9dec3dab`, a probe
battery since deleted)

- **P6** (record view, row `crm_lead.mine`, body `name`
`crm_lead.other`): accepted. The registry key was `crm_lead.other` only.
- **P7** (dashboard, row `dash_a`, body `name` `dash_b`): accepted. The
registry key was `dash_b` only.
- **R1** (`rollbackMetaItem` to a stored version whose body `name` is
`dash_b`): it restored that version with 0 `saveMetaItem` calls. The key
was `dash_b`.
- **R2** (`revertCommit`, `prevVersion` that version): `revertedCount:
1` with 0 `saveMetaItem` calls. The key was `dash_b`.
- **D1** (`publishMetaItem` of a draft row `dash_d` whose body `name` is
`dash_e`): promoted with 0 `saveMetaItem` calls. The key was `dash_e`.
- **An empty or non-string `name` on a non-container type** (the seat's
added pin), measured per type against `getMetadataTypeSchema`:
- 24 of the 27 schema'd registry types already refuse `''`, `7` and
`null` (422).
  - `seed` declares no `name` at all, so it refuses any `name`.
  - `view` stamps a falsy `name` (and the schema refuses `7`).
  - `translation` **accepts `name: ''`**.
  - `external_catalog` has no schema, so it accepts anything.
- So `''` reaches persistence for `translation`, and it is keyed `''` in
the registry; any value reaches persistence for `external_catalog`. The
judge therefore refuses a set non-view `name` whatever its value. See
the first item under the decisions below.

## Pins

All in
`packages/metadata-protocol/src/protocol.item-name-every-door.test.ts`.
It is a stub engine that stores rows and history, whose registry keys an
item by its `name`, and whose transaction rolls back on a throw
(ADR-0067 D2). It runs on the topology where non-`object` types write
through to the shared registry.

- **P6, P7, and `translation` with `name: ''`:** refused with
`VALIDATION_ERROR` / 400. Nothing is stored and nothing is registered.
- **Equal or absent `name` passes:** a dashboard stored and keyed
`dash_a`. A nameless record view is stamped and keyed by its row. A
nameless dashboard passes the judge and meets its schema's own 422.
- **One registry key per row:** asserted on every control.
- **R1, R2 and D1 refused with P6/P7's envelope, nothing written.** The
active row, the history length and the registry are unchanged; there are
no `saveMetaItem` calls; the draft is kept. Each has a clean control
through the same door.
- **The `publishPackageDrafts` batch case:** one refused draft aborts
the batch, as the authoring gate's refusal does. The outcome is
`refused` and `failed[]` lists the refused draft with `VALIDATION_ERROR`
and its sibling as aborted. Nothing goes live, and the registry is
empty. A clean control publishes both.
- **The judge's own pins**
(`packages/metadata/src/view-container-name.test.ts`): every type; every
door; what counts as set (row 1's predicate, the view stamp only at the
save door); the remedy per type and per door; and `field`.
- The SCOPE pin ("a standalone ViewItem is not judged here") flips by
design.

The stored bodies that R1, R2, D1 and the batch case need are staged the
way they exist in a deployment. A clean body goes through the real door,
and its stored bytes are then rewritten in the double, because after
this change no door writes one.

## Ablation and reverse verification (each from a committed state,
through `scripts/ablation-replace.mjs`)

Each run's direction was declared before it ran, and each observed
result matched:

| run | mutation | result |
|---|---|---|
| A1 | neutralize the `saveMetaItem` call (src) | the every-door file 3
red / 9 green (P6, P7, `translation`);
`view-container-runtime-expansion.test.ts` 5 red (objectstack-ai#21412's P1 ×3, P3,
P4) |
| A2 | neutralize the restore writer's judge (src) | 2 red / 10 green
(R1, R2) |
| A3 | neutralize the publish judge (src) | 2 red / 10 green (D1, the
batch case) |
| Reverse, through `dist` | the judge's write-door entry put back to
container-only, in `packages/metadata/src`, then rebuilt |
`ablation-dist-preflight` found the marker in 2 built files; the
every-door file 7 red / 5 green (every refusal red, every control
green); the container file stays green |

- **Every run restored cleanly.** Each restore leg ended with the blob
equal to `HEAD` and an empty `git diff HEAD`.
- **The reverse run's restore leg:** a rebuild, the `--absent`
preflight, and 12 / 12 green.
- **A refused first attempt:** the first reverse attempt was refused by
the tool before anything ran, because its replacement contained the
anchor. It was recorded as a non-run and rerun with a non-overlapping
replacement.

## Tests (at `181408e1e5`; core pins again at `056df2c896` after the
last merge of `main`)

- `@objectstack/metadata`: `src/view-container-name.test.ts` passes 19 /
19, and the full suite earlier passed 858 / 858.
- `@objectstack/metadata-protocol`: the full suite passes 3163, with 19
skipped. At `056df2c896` the every-door and container files pass 131 /
131.
- **Downstream files that exercise the write doors,** run at the earlier
head with a rebuilt `metadata-protocol` `dist`:
  - `objectql`: 42 files, 534 tests;
  - `rest`: 53 files, 1363 tests;
  - `runtime`: 46 files, 1557 tests;
  - `plugin-security`: 7 files, 150 tests;
  - `service-automation`: 2 files, 8 tests;
- `plugin-email`, `service-cluster`, `mcp`, and `cli` (unit tier): 2 + 1
+ 2 + 2 files.
  - All green after the fixture triage below.
- `typecheck`: `metadata`, `metadata-protocol` and `objectql` all green
(the last including `check:test-typecheck`, 65 pinned signatures held).
`--listFiles` confirms the new and edited test files are compiled.
- **Lint, a declared narrowing.** `eslint --no-inline-config --format
json` over the 13 touched `.ts` files gave 13 results, 0 errors, 0
warnings, and none ignored. The touched population is all of them:
`eslint.config.mjs` lints `**/*.{ts,…}` minus its `NEVER_LINTED` set.
Untouched files cannot move, because the config enables no type-aware
linting (no `parserOptions.project`, as its own header states).
- **Left to CI:** `packages/qa/dogfood` (25 files touch these doors; the
PUT bodies I read there name their row or echo a GET),
`qa/http-conformance`, and the `cli` integration tier.

## Gates

`dispatch-gates --commands` on the final diff derived 74 families, a
superset of the PM's lead. The 74 are its 56 plus 18: the changeset,
objectql and ledger families. `--ran` with each exit code recorded
answered `74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED`.
73 exited 0. At `056df2c896` the ratchet family was rerun:
`engine-double-contract`, `objectql-double-limit`,
`query-options-erasure`, `slot-lookup`, `where-matcher`,
`type-check-debt`, `type-check-coverage`, `doc-authoring`,
`cross-package-test-inputs`, `test-source-alias`, `nul-bytes`,
`keyed-text-bounds`, `undeclared-dep-imports`, `adr-0087-registration`
and `changeset-no-major`. All exited 0.

- **`check:engine-double-contract`** asked for the new test's pinned
double to be recorded (`--write`). That is the 15-line addition to
`scripts/engine-double-contract.pinned.json`, and nothing else moved.
- **`check-empty-changeset` exits 1, deliberately: it is a DELIBERATE
CORRECTION.** It needs confirmation on this PR (see below).
- **`check-changeset-no-major`'s clause-② axis** reads `NOT APPLICABLE`
locally (there is no `pull_request` payload); CI reads it on this PR.

## Changesets

- `.changeset/21470-metadata-write-door-item-name-judge.md`:
`@objectstack/metadata`, minor, `Clause-②: yes`.
- `.changeset/21470-metadata-protocol-every-write-door-item-name.md`:
`@objectstack/metadata-protocol`, minor, with the **BREAKING** banner
and `Clause-②: no (narrowing)`. Its ADR-0087 disposition is
`not-required (no-migration-prescription)`, with the census (0 rows) in
the marker, as PR objectstack-ai#21483's was.
- **Two pending objectstack-ai#21412 release notes are corrected, because this PR
makes a sentence in each false.** They are named here for confirmation,
as `check-empty-changeset` asks:
- `.changeset/21412-metadata-view-container-name-judge.md`: the sentence
naming `savedViewContainerNameRefusal(container, saveName)` now names
`savedItemNameRefusal(type, item, saveName, door)` and says it judges
every type. The seat ordered this one (5964758196).
- `.changeset/21412-metadata-protocol-save-door-container-name.md`: its
last line read "Not judged here: a standalone view record (`viewKind`)
and every other metadata type". The same release now judges them, so the
line points to this PR's entry. ⚠ The seat's answer named only the first
note. This second one is my addition, under "every changeset sentence
must be true".

## Decisions the review should check

1. **An empty or non-string `name` on a non-view type is refused by the
judge,** which runs before the schema. Where the type's schema already
refused such a body (`''`, `7` or `null` on 24 types; any `name` on
`seed`), the answer moves from the schema's `INVALID_METADATA` / 422 to
`VALIDATION_ERROR` / 400. Nothing is stored either way.
- The seat's text said "If the schema already refuses it, record that
and add nothing". I did not make the judge schema-aware to honour that
per type, because that would be a second rule keyed on schema knowledge.
One predicate (row 1's) covers both `translation`'s `''` and
`external_catalog`'s anything.
   - The changeset says so.
2. **`field`.** A `field` row is named `object.field`, and its canonical
body carries the dot-free column `name`. Registered, the row answered
under the column name, and every object's `title` field collided on one
key. That is pin 3's defect, so the judge refuses it, and the remedy is
"drop `name`".
- The type is code-only (objectstack-ai#5086) and was ruled REMOVE (objectstack-ai#7893), so this is
reachable only through the `OS_METADATA_WRITABLE` operator hatch.
- The hatch-path fixtures in two test files now send a nameless field
body: `protocol.code-only-types.test.ts` and
`protocol.destructive-gate-reachable-types.test.ts`. What those tests
measure (the hatch's routing, the destructive gate's reach) is
unchanged.
- The alternatives were to exempt `field` (which keeps the collision) or
to judge it against the column half of its row name (a type-specific key
derivation).
3. **The `door` parameter and the two-direction remedy go beyond the
seat's suggested `savedItemNameRefusal(type, item, saveName)`.** They
exist so the remedy is true at a door whose caller cannot edit the
stored body, and for a save name the type cannot spell.

## Fixture triage (bodies that only used a constant `name`)

The rule's consumer radius covers other packages' fixtures, so they were
swept and re-judged. Each fixture below only used the `name`, so each
was rewritten to name its row, or to send none where the door stamps
one. What each test measures is unchanged.

- `metadata-protocol`: `protocol.item-name-grammar.test.ts` (`VIEW_BODY`
is now nameless; the door stamps the request name) and
`protocol.runtime-gate-stored-universe.test.ts` (`oneWidgetBoard` takes
the row name).
- **`objectql`:**
  - `protocol-recorded-by-null.test.ts` (`viewBody` takes the row name);
  - `protocol-save-meta-repo-path.test.ts` (`view_one` becomes `v`);
- the two `*-meta-response-conformance.test.ts` files (`cleanFlow` is
named `bounded_purge`, the row it is saved under).
- `field`: see the decisions above.

## Not in this PR

- **Boot hydration** (`loadMetaFromDb`, then
`hydrateOverlayIntoRegistry`) keeps registering a row stored before this
change under its body `name`.
- It is residue only: once the write doors judge, no new row can
diverge.
- The census found 0 such rows on the examples; the hosted tenant is NOT
MEASURED.
- It is a reader, outside this claim (⛔ not `getMetaItem`,
`readFlattenedMetaItems` or `hydrateExpandedViewItems`; objectstack-ai#21510 and
objectstack-ai#21511 are queued behind this card).
  - A measured hosted instance would be the trigger to file it.
- **A non-view body with no `name`** still registers nothing at all
(`hydrateOverlayIntoRegistry` skips a nameless body). This is
pre-existing, and triage's pin 2 says an absent `name` passes.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…serve write returns at the reader-context seams (objectstack-ai#21539)

Part of objectstack-ai#21454

Clause-②: yes (narrowing)

The follow-on to PR objectstack-ai#21513, on the same reader-context seam. objectstack-ai#21513
served the stored-metadata-body family's reads (body projected, content
hash keyed) at the in-process reader contexts. This PR closes the two
positions triage routed to the card's next claim, through the generic
data door's own code — no copy:

- **Evaluate-shape refusals** (triage `5964426684` item 2, with
`5963299937`). A filter, sort, grouping or search that would EVALUATE
the stored body or a content-hash column of the family, arriving through
a reader context (`ctx.api.object(...)` for action and hook bodies, a
handler's `ctx.api`, and `ctx.engine.find`), is now refused with the
door's own `INVALID_FIELD` / 400 before the query runs. A `count`
carrying such a predicate — the oracle verb — is refused too (it serves
no row). A default search is NARROWED to the door's served field set
(the body and content-hash columns removed, judged field by field by the
door's own search predicate) rather than refused, so a reader context
may still search a family table by its scalar columns exactly as the
door serves it; a search that narrows to nothing is refused.
- **Serve what a write verb returns** (triage `5964426684` item 4). A
write whose return carries the family's body or content hash is served
projected and keyed, the same way a read is — a returned row is a serve.

Both are executed through the four refusal predicates the door uses, now
exported from `@objectstack/metadata-protocol`
(`storedMetadataBodyGroupingRefusal`,
`storedMetadataBodyPredicateRefusal`,
`storedMetadataHashEvaluateRefusal`, `storedMetadataSearchRefusal`). The
search narrowing consumes the spec's own `resolveSearchFieldResolution`
and the door's search predicate as the authority on which columns a
search may never scan. Field collection for the filter/sort refusals
uses `@objectstack/plugin-security`'s `collectConditionFields` (the
door's sibling collector; the door's own `collectFilterFieldKeys` is
internal to `protocol.ts`, PR objectstack-ai#21473's file, and unreachable here). That
collector gates on a dotted head and reads a cross-field reference, so
it refuses MORE than the door — a dotted or cross-field reference to a
family column the door's collector would miss — strictly in the safe
direction, never a legitimate scalar-column query.

### The engine action verb (`ScopedRepo.execute`), measured (triage
`5964836549` item 1)
The reach reading: `execute` is UNREACHABLE from a served body. The
sandbox VM bridge exposes only `find` / `findOne` / `count` /
`aggregate` and the writes to a body — no `execute`, no `sudo`, no
`withRunAs` — so a served body can never hand a raw scoped context to a
nested action. The seam therefore leaves `execute` untouched and records
the reading (pinned: a body's `typeof ctx.api.object(...).execute` is
`undefined`).

### Where objectstack-ai#21520's write-verb refusal attaches
The maintainer approved objectstack-ai#21520 option A (an app-authored body may not
write the family's tables). That refusal is a SEPARATE card and is NOT
implemented here. It attaches on the same write verbs this seam wraps,
in `serveRepository`'s write branch, as a throw BEFORE the write runs —
it needs no reshaping of this seam. A code comment names that point.
Measured on `main` today (pre-objectstack-ai#21520): an elevated body's family-table
write is not refused and returns the stored row, so the write-return
serve in this PR carries real content; it also covers the host-handler
write path, which objectstack-ai#21520's body refusal does not reach.

### Reverse verification (ablation)
Each new behavior ablated on disk (`scripts/ablation-replace.mjs`,
mutation proven by blob hash + anchor count, restored to the HEAD blob),
src-resolved (the pins import the seam by relative path):
- disabling the evaluate refusal turned the 6 refusal / narrowing pins
red; the serve, write-return, scalar and unreachability pins stayed
green;
- disabling the write-return serve turned exactly the write-return pin
red; all else green.

### Tests and gates
- New pins: `stored-metadata-reader-seam.test.ts` (17) against a
scoped-API double, and `stored-metadata-reader-contexts.pin.test.ts`
(26) end to end through a booted kernel, for administrator and member
alike.
- `@objectstack/runtime` suite 4413 passed / 19 skipped;
`@objectstack/metadata-protocol` suite 3151 passed / 19 skipped; both
typecheck clean.
- All 70 dispatch-derived gate families green
(`check:engine-double-contract` pinned-ledger entry added through the
gate's own `--write`).
- Lint narrowing: the 4 changed TS files lint 0 errors / 0 warnings; the
`.md` and `.json` have no matching eslint config; `eslint.config.mjs`
enables no type-aware linting and no cross-file import rules, so this
diff cannot move the verdict on any untouched file (the farm-wide `pnpm
lint` is CI's).

---
_Generated by [Claude
Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ion in words instead of a tracker number (stage 5) (objectstack-ai#21568)

Part of objectstack-ai#20749
Clause-②: no

Stage 5 of the `domain:spec` lane's share of the runtime-string
burn-down (ruling `5902360492`, form D): the rest of class (b), the
protocol 17 → 18 conversion summaries in
`packages/spec/src/conversions/registry.ts`. Every rewritten summary now
states in words what the cited decision was, or drops a citation its
sentence already explained. Text only.

## What changed

- **35 ADR-0087 conversion summaries** (40 tracker ids, 34 distinct
cards): every toMajor-18 summary that carried an id, from
`field-malformed-scale-precision-removed` to
`flow-decision-mode-inclusive-explicit`. A summary is what `os migrate
meta --json` reports under `specChanges` (its chain already runs to
protocol 18, `CHAIN_TERMINUS_MAJOR`), and it becomes the upgrade guide's
"Change" column and the `to` text of `spec-changes.json`'s `converted[]`
once protocol 18 ships, so an author upgrading metadata reads it.
- One `@objectstack/spec` **patch** changeset, `Clause-②: no` (message
text only).
- **No generated file changes** (A2 below): no generator projects a
toMajor-18 summary today.

## Census at the base (A1)

Stage 4's instrument (`convtable.cjs`, byte-identical copy, md5
`9b7539067ffc38598172c692de637db5`) at base `e901c27449` (the worktree
before any edit): 127 conversions, **35 summaries with ids, 40 id
occurrences, 34 distinct cards** — stage 4's split unchanged. The
toMajor-18 conversion that PR objectstack-ai#21547 added carries no id. Under the
~60-card bar, so one stage. The stage-3 census re-run at the same base
agrees (class b: 35 messages / 40 ids; nothing else in class b).

| file:line (base) | id | conversion |
|---|---|---|
| `registry.ts:6551` | objectstack-ai#8321 | `field-malformed-scale-precision-removed`
|
| `registry.ts:6659` | objectstack-ai#8762 | `record-chatter-position-vocabulary` |
| `registry.ts:6777` | objectstack-ai#9198 | `element-input-target-variable-removed` |
| `registry.ts:7024` | objectstack-ai#9220 | `element-filter-removed` |
| `registry.ts:7177` | objectstack-ai#9249 | `element-form-removed` |
| `registry.ts:7356` | objectstack-ai#15178 | `translation-per-app-settings-removed` |
| `registry.ts:7356` | objectstack-ai#19620 | `translation-per-app-settings-removed` |
| `registry.ts:7610` | objectstack-ai#9249 |
`translation-component-submit-label-removed` |
| `registry.ts:7782` | objectstack-ai#3951 | `field-column-lists-canonicalized` |
| `registry.ts:7783` | objectstack-ai#9227 | `field-column-lists-canonicalized` |
| `registry.ts:7909` | objectstack-ai#10414 | `metric-filters-removed` |
| `registry.ts:8104` | objectstack-ai#17296 | `cube-sub-day-granularities-removed` |
| `registry.ts:8237` | objectstack-ai#18612 | `cube-join-sql-and-relationship-removed`
|
| `registry.ts:8502` | objectstack-ai#10054 | `record-highlights-field-icon-removed` |
| `registry.ts:8759` | objectstack-ai#11027 | `page-component-responsive-removed` |
| `registry.ts:8860` | objectstack-ai#11805 | `object-grid-default-sort-removed` |
| `registry.ts:9047` | objectstack-ai#21445 | `object-grid-resizable-columns-removed`
|
| `registry.ts:9250` | objectstack-ai#17260 | `object-kanban-quick-add-removed` |
| `registry.ts:9634` | objectstack-ai#12497 | `permission-allow-restore-purge-removed`
|
| `registry.ts:9637` | objectstack-ai#1883 | `permission-allow-restore-purge-removed`
|
| `registry.ts:9954` | objectstack-ai#6837 | `field-reference-to-alias` |
| `registry.ts:10372` | objectstack-ai#14478 | `hook-timeout-to-timeout-ms` |
| `registry.ts:10413` | objectstack-ai#14478 | `job-timeout-to-timeout-ms` |
| `registry.ts:11017` | objectstack-ai#14478 |
`api-endpoint-cache-ttl-to-cache-ttl-seconds` |
| `registry.ts:11086` | objectstack-ai#14478 |
`dashboard-refresh-interval-to-refresh-interval-seconds` |
| `registry.ts:11447` | objectstack-ai#14478 |
`memory-persistence-auto-save-interval-to-ms` |
| `registry.ts:11671` | objectstack-ai#14478 | `turso-config-timeout-to-timeout-ms` |
| `registry.ts:11761` | objectstack-ai#17063 | `view-page-mount-removed` |
| `registry.ts:11866` | objectstack-ai#17053 | `list-view-sort-string-clause-to-array`
|
| `registry.ts:11868` | objectstack-ai#8221 | `list-view-sort-string-clause-to-array`
|
| `registry.ts:12366` | objectstack-ai#19054 |
`object-tenancy-organization-field-removed` |
| `registry.ts:12476` | objectstack-ai#20085 | `view-item-owner-hidden-removed` |
| `registry.ts:12620` | objectstack-ai#20230 | `view-overlay-owner-hidden-removed` |
| `registry.ts:13214` | objectstack-ai#17321 |
`page-component-filter-record-to-rule-array` |
| `registry.ts:13214` | objectstack-ai#6206 |
`page-component-filter-record-to-rule-array` |
| `registry.ts:13463` | objectstack-ai#20161 | `report-joined-chart-removed` |
| `registry.ts:13728` | objectstack-ai#20221 | `form-layout-inline-grid-to-vertical` |
| `registry.ts:13884` | objectstack-ai#19992 | `currency-config-precision-removed` |
| `registry.ts:13988` | objectstack-ai#20321 | `permission-rls-tags-removed` |
| `registry.ts:14128` | objectstack-ai#15429 | `flow-decision-mode-inclusive-explicit`
|

## Projections (A2)

Neither generator projects a toMajor-18 summary at this base.
`build-spec-changes.ts` and `build-upgrade-guide.ts` both loop `major`
from `MIGRATION_SUPPORT_FLOOR + 1` to `PROTOCOL_MAJOR`, which are 16 and
17 here (`PROTOCOL_VERSION = '17.0.0'`), so `spec-changes.json` carries
one `perMajor` record (16 → 17) and the guide one "Protocol 16 → 17"
table. `check:generated` reads all 15 artifacts up to date on this head
with no regeneration, so no generated file is in the diff. Both
projections will pick these summaries up when protocol 18 ships.

## Delivered: each site, the decision read, the new words (A3)

Every cited card was read through REST with all its comments (30
objectstack cards, objectui#3951, objectstack-ai#6206, objectstack-ai#6837, objectstack-ai#8221). The record
column names the comment the decision was read from. Where a summary
already said why, the citation is dropped and the sentence kept; where
an `(#N, ADR-0049 — …)` opener cited both, the card number goes and the
ADR stays, as stage 4 did. In the
`cube-join-sql-and-relationship-removed` row, `ALIAS` stands for the
angle-bracket placeholder in the source.

| conversion (head line) | cited | decision as read (record) | summary
now reads |
|---|---|---|---|
| `field-malformed-scale-precision-removed` (:6550) | objectstack-ai#8321 | refuse a
malformed scale/precision at the producer (z.number().int().min(0)); a
stored malformed value takes the D2 strip so the row stays loadable;
citation dropped, the sentence already said it (body + ACCEPT
5296942541) | malformed field 'scale'/'precision' declarations
(non-integer or negative) are removed — they were silently unenforced;
the schema now refuses them at authoring |
| `record-chatter-position-vocabulary` (:6658) | objectstack-ai#8762 | the row's
vocabulary converges on the renderer's bottom/right/left: one
vocabulary, no mapping layer; the three old spellings take a conversion
(ruling 5299771841) | record:chatter / record:discussion 'position'
respelled to the renderer's vocabulary — 'sidebar' → 'right', 'inline' →
'bottom', 'drawer' → 'right' (one vocabulary, the renderer's, rather
than a mapping layer between two: the renderer compares only
bottom/right/left, and the old set fell through every branch) |
| `element-input-target-variable-removed` (:6778) | objectstack-ai#9198 | ADR-0049
enforce-or-remove: verdict dead (a declarative hint with zero readers),
retired with tombstones and a D2 conversion (ACCEPT 5311252358 (PR body
verdict)) | text-input/record-picker component prop 'targetVariable'
removed (retired under ADR-0049 enforce-or-remove as a declarative hint
nothing read; the live binding resolves from the page variable whose
`source` names the component id) |
| `element-filter-removed` (:7025) | objectstack-ai#9220 | dead at ELEMENT grain (no
renderer anywhere; Studio excludes it from the palette), so the whole
element retires under ADR-0049, not key by key (verdict 5312176877 +
ACCEPT 5312709553) | the whole 'element:filter' element retired
(ADR-0049 enforce-or-remove at element grain, not key by key — no
renderer for it ever shipped in any repo, so every key was a capability
claim nothing kept; list surfaces own their filtering via a view's
userFilters / the list filter builder). All six props are stripped; the
bare node the conversion leaves is refused by name at the parse, with
the prescription to delete the component |
| `element-form-removed` (:7179) | objectstack-ai#9249 | dead at element grain, the
objectstack-ai#9220 precedent: the whole element retires; the palette already names
object-form as the replacement (dev report 5384430470 (verdict re-taken
in the PR body)) | the whole 'element:form' element retired (ADR-0049
enforce-or-remove at element grain, not key by key — no renderer for it
ever shipped in any repo, so every key was a capability claim nothing
kept; use the object-bound 'object-form' block instead — rendered and
designer-publishable). All six props are stripped; the bare node the
conversion leaves is refused by name at the parse, with the prescription
to delete the component |
| `translation-per-app-settings-removed` (:7358) | objectstack-ai#15178, objectstack-ai#19620 |
objectstack-ai#15178: the bundle type splits, the platform bundle keeps `settings`, a
per-app bundle refuses it (settings is a platform key). objectstack-ai#19620 ruling B:
`settings` leaves the translation item too, because the file door and
the item door are two authoring surfaces of one app metadata type and
accept one shape (ruling 5653315643 (objectstack-ai#15178); ruling 5770445203
(objectstack-ai#19620)) | translation group 'settings' removed from both
application-authored faces, the per-app bundle entry and the registered
translation item: settings copy belongs to the platform, and the two
authoring doors of one application translation type accept one shape. It
is keyed by SettingsManifest.namespace and only platform code declares a
manifest. A per-app bundle entry could only fill gaps the platform's own
bundle left in the one merged served tree, and was overwritten wherever
both defined the key; a stored item OVERRODE the platform copy, because
the runtime-authored layer is read over the shipped bundles. Overrides
now give way to the platform copy, gaps fall back to the manifest
literal, and the group stays on the PLATFORM bundle,
PlatformTranslationData |
| `translation-component-submit-label-removed` (:7613) | objectstack-ai#9249 |
`element:form` retired whole because no renderer for it ever shipped
(dead at element grain), which left `submitLabel` with no carrier (dev
report 5384430470) | translation component-copy key 'submitLabel'
removed (retired rather than re-anchored — its only declared carrier,
'element:form', retired whole because no renderer for it ever shipped,
so the resolver no longer overlays it and a stored string was read by
nothing; the live form surface's submit copy is 'object-form''s
'submitText', localized at its own authoring site, and re-anchoring the
key there would only have added a second place to translate one word) |
| `field-column-lists-canonicalized` (:7787) | objectui#3951, objectstack-ai#9227 |
objectui#3951: the published spec spelling `name` wins and the grid
reader is fixed to read it. objectstack-ai#9227: `inlineColumns` gets a strict
name-keyed element schema (an unknown key is a named rejection at
publish, not a blank cell); `relatedListColumns`, checked in the same
pass, takes field-name strings (ruling 5236150020 (objectui#3951);
ruling 5315735776 + ACCEPT 5317488979 (objectstack-ai#9227)) | inline-grid column
entries respelled 'field' → 'name' (the declared spelling wins, and the
grid renderer now reads 'name' too) and related-list column objects
folded to their child field-name string (both lists were z.any(), so a
mis-keyed column published clean and rendered blank cells; inline
columns now take a strict name-keyed shape and related-list columns
plain field names, so a mis-keyed column is refused at publish) |
| `metric-filters-removed` (:7916) | objectstack-ai#10414 | the remove leg of
enforce-or-remove: zero consumers (measured with a positive control) and
a raw-SQL carrier; retire per the playbook; citation dropped, the
sentence already said it (triage grading 5363699539) | cube metric key
'filters' removed (ADR-0049 — no strategy ever read it: the authored
raw-SQL condition was parsed and dropped, and the query returned the
unfiltered aggregate. Filter at query time with `where`, or use an
ADR-0021 dataset measure's structured `filter`; a metric's own `sql` is
a column reference) |
| `cube-sub-day-granularities-removed` (:8111) | objectstack-ai#17296 | each of
second/minute/hour is residue and removed: no layer outside the enum
names them and `queryDateGranularity` cannot advertise them; ADR-0049
prefers removal with no committed roadmap; citation dropped (dev report
5648182389 + landing 5648923185) | cube dimension granularities 'second'
/ 'minute' / 'hour' removed (ADR-0049 — no backend bucketed them and
none could advertise them: `supports.queryDateGranularity` is a record
over `DateGranularity`, which declares day, week, month, quarter, year.
Offer the coarsest interval that still answers the question) |
| `cube-join-sql-and-relationship-removed` (:8244) | objectstack-ai#18612 | retire
`sql` and `relationship` from CubeJoin: the join is derived from the FK
relationship and no author-supplied ON clause executes; the addendum
adds the D2 strip for persisted artifacts; citation dropped, the
sentence already said it (ruling 5725370783 + addendum 5727426171) |
cube join keys 'sql' and 'relationship' removed (ADR-0049 — neither was
ever read: both strategies synthesise the ON clause as a foreign-key
equality, so an authored join condition was REPLACED under a 200 and a
declared cardinality changed no SQL. Keep `joins.ALIAS.name` alone; the
record KEY is the foreign-key field on the base object) |
| `record-highlights-field-icon-removed` (:8509) | objectstack-ai#10054 | option A:
measured dead (zero read points, not designer-publishable), so it
retires under the ADR-0087 flow; citation dropped (ruling 5364978909) |
record:highlights highlight-field key 'icon' removed (ADR-0049 — no
render path: the highlight chip has no icon slot, the register hook
carries field names only, and the Studio designer publishes the field
list as plain strings, so an authored icon was accepted and drawn by
nothing) |
| `page-component-responsive-removed` (:8766) | objectstack-ai#11027 | ruling B:
retire `page.components[].responsive` (ADR-0049, wired into no renderer)
and repair the texts that redirected authors to it (ruling 5380752244) |
page component key 'responsive' removed (ADR-0049 enforce-or-remove — no
renderer ever applied per-component breakpoint layout overrides, and the
shared ResponsiveConfig shape leaves with its last carrier; use
responsiveStyles (ADR-0065) for breakpoint behaviour that IS applied) |
| `object-grid-default-sort-removed` (:8868) | objectstack-ai#11805 | retire
object-grid `defaultSort` (the strict route per the playbook),
completing the objectui-side direction ruling at the producer (ruling
5404972152) | object-grid component prop 'defaultSort' removed (retired
under ADR-0049 enforce-or-remove as the legacy single-sort second
spelling of 'sort', read only when 'sort' was absent; the pair moves to
sort: [{ field, order }], the array shape every read path honours) |
| `object-grid-resizable-columns-removed` (:9055) | objectstack-ai#21445 | `resizable`
is canonical and `resizableColumns` retires now as a tombstone naming
it: zero writers, so no window (immediate retirement) (triage direction
5958164933) | object-grid component prop 'resizableColumns' removed (the
legacy second spelling of 'resizable', read only when 'resizable' was
absent, retires at once so 'resizable' is the one spelling; the value
moves to 'resizable' when that is absent, and is deleted when it is
present) |
| `object-kanban-quick-add-removed` (:9258) | objectstack-ai#17260 | option B:
`quickAdd` leaves `object-kanban` (accepted and dropped there); this
repo carries the tombstone half (card body (the objectui ruling it
executes, option B) + triage 5620331176) | object-kanban component prop
'quickAdd' removed (retired from the board under ADR-0049
enforce-or-remove — the affordance is gated on a host-supplied
'onQuickAdd' function no producer puts on an object-kanban node, so the
key was accepted and dropped; delete the key — object-kanban offers no
quick-add control) |
| `permission-allow-restore-purge-removed` (:9643) | objectstack-ai#12497, objectstack-ai#1883 |
option B: retire `allowRestore` / `allowPurge`, which gate operations
that do not exist; objectstack-ai#1883 stays open as the M2 anchor, where
undelete/purge ship as feature + RBAC in one batch and the keys return
with it (card body (objectstack-ai#12497); ruling 5421209848 (objectstack-ai#1883)) |
object-permission keys 'allowRestore' and 'allowPurge' removed (ADR-0049
— the `restore`/`purge` operations they claimed to gate have never
existed, so granting the bits delivered nothing; dispatched destructive
lifecycle verbs stay denied fail-closed. The keys return with the M2
lifecycle initiative, which builds undelete and purge together with the
permission bits that gate them) |
| `field-reference-to-alias` (:9962) | objectui#6837 | ruling C:
protocol normalisation belongs to the server and the frontend only
executes the protocol; half 1 (this repo) guarantees the serve path
carries only `reference`, half 2 deletes objectui's legacy fallback arms
(ruling 5475017957 + half-1 pointer 5475055291) | field key
'reference_to' → 'reference' (the legacy objectql runtime dialect for a
lookup/master_detail target; normalising to the protocol is the server's
job and the renderer only executes the protocol, so stored rows must
serve the canonical spelling before objectui deletes its `reference ??
reference_to` fallback arms) |
| `hook-timeout-to-timeout-ms` (:10383) | objectstack-ai#14478 | ruling B: a
duration-shaped number key carries its unit in its name (or a
unit-carrying value), every existing offender renamed under an ADR-0087
conversion, no grandfathered baseline (ruling 5518649320 + population
ruling 5548763981) | hook key 'timeout' → 'timeoutMs' (a duration key
carries its unit in its name, and this one's unit lived only in the
description; the value, milliseconds, is unchanged) |
| `job-timeout-to-timeout-ms` (:10424) | objectstack-ai#14478 | as above (as above) |
job key 'timeout' → 'timeoutMs' (a duration key carries its unit in its
name, and this one's unit lived only in the description; the value,
milliseconds, is unchanged) |
| `api-endpoint-cache-ttl-to-cache-ttl-seconds` (:11028) | objectstack-ai#14478 | as
above (as above) | api endpoint key 'cacheTtl' → 'cacheTtlSeconds' (a
duration key carries its unit in its name, and this one's unit lived
only in the description; the value, seconds, is unchanged, and the key
stays GET-only) |
| `dashboard-refresh-interval-to-refresh-interval-seconds` (:11097) |
objectstack-ai#14478 | as above (as above) | dashboard key 'refreshInterval' →
'refreshIntervalSeconds' (a duration key carries its unit in its name,
and this one's unit lived only in the description; the value, seconds,
is unchanged) |
| `memory-persistence-auto-save-interval-to-ms` (:11458) | objectstack-ai#14478 | as
above (as above) | memory datasource key
'config.persistence.autoSaveInterval' → 'autoSaveIntervalMs', on both
the file and auto arms (a duration key carries its unit in its name, and
this one's unit lived only in the description; the value, milliseconds,
is unchanged) |
| `turso-config-timeout-to-timeout-ms` (:11682) | objectstack-ai#14478 | as above (as
above) | turso datasource key 'config.timeout' → 'config.timeoutMs' (a
duration key carries its unit in its name, and this one's unit lived
only in the description and a .meta() title no parse reads; the value,
milliseconds, is unchanged) |
| `view-page-mount-removed` (:11772) | objectstack-ai#17063 | the maintainer chose to
retire (「撤」) over finishing the objectui render half or parking it: the
`page` member and its mount leave the spec under enforce-or-remove (card
body (the maintainer ruling it records)) | list-view type 'page' and its
`pageName` binding removed (retired rather than finished: the delegating
render half was never built, so a page view fell through to the grid
branch and drew an empty table; ADR-0049 enforce-or-remove) |
| `list-view-sort-string-clause-to-array` (:11877) | objectstack-ai#17053,
objectui#8221 | objectui#8221 option B: the legacy string `sort` is
retired, one spelling platform-wide, the array. objectstack-ai#17053: the spec slot
that produces those documents stops accepting the string objectui now
refuses (triage 5620223775 (objectstack-ai#17053); ruling 5567944420 (objectui#8221))
| the bare string list-view `sort` clause becomes the `{ field, order
}[]` array (one sort orthography platform-wide, the array: objectui
already refuses the string, so the schema stops minting documents its
own consumer refuses) |
| `object-tenancy-organization-field-removed` (:12377) | objectstack-ai#19054 | take
`organizationField` off the authorable surface; its one real use stays a
platform-internal fact; citation dropped, the sentence already said it
(card body (the maintainer ruling it records)) | object
`tenancy.organizationField` removed (ADR-0049 — the stamp-only column
declaration was authorable by every application and declared exactly
once in the whole protocol, on the platform's own credential table; the
divergence moves to a platform-internal table in
@objectstack/metadata-core and stops being a knob) |
| `view-item-owner-hidden-removed` (:12487) | objectstack-ai#20085 | retire both keys
(ADR-0049 enforce-or-remove, zero pull) via the retirement playbook;
citation dropped (triage direction 5826969296) | view item keys
'owner'/'hidden' removed (ADR-0049 — declared on the view item record
and stored verbatim, read by nothing: no view switcher ever filtered on
`hidden`, and no per-user scope ever read `owner`, so a view marked as
one user's was listed for everyone) |
| `view-overlay-owner-hidden-removed` (:12631) | objectstack-ai#20230 | follow
objectstack-ai#20085's disposition for the same key pair on the overlay door: the same
retirement (triage direction 5856621469) | flattened view overlay keys
'owner'/'hidden' removed (ADR-0049 — the view item's pair on the overlay
door, retired the same way: declared, accepted by the write door and
stored verbatim, read by nothing, so a `hidden: true` overlay hid no
view and an `owner` scoped none) |
| `page-component-filter-record-to-rule-array` (:13220) | objectui#6206,
objectstack-ai#17321 | objectui#6206 option B: one filter orthography platform-wide,
the rule array. objectstack-ai#17321 ruling B: a partial D2 conversion of what maps
losslessly; combinator-carrying rows pass through untouched and are
named as a TODO (flattening would silently change what a page selects)
(ruling 5406409590 (objectui#6206); ruling 5644018752 (objectstack-ai#17321)) | a
record-form or single-level AST filter at a converged rule-array door
becomes the `[{ field, operator, value }]` rule array wherever the
mapping is lossless (flat keys → `equals` rules, `{ $op: v }` → the
mapped operator, AST comparisons → one rule each); a filter carrying
`$and` / `$or` / `$not` or any part with no lossless rule spelling is
left exactly as stored — reported as a TODO, which `os migrate meta
--stored` lists — and is not the form its door declares (one filter
orthography platform-wide, the rule array; the migration converts only
what maps losslessly and names the rest, because flattening a combinator
would silently change what a page selects) |
| `report-joined-chart-removed` (:13477) | objectstack-ai#20161 | retire, not build
block charts: the joined arm refuses a container chart, the block key
goes, a non-joined report keeps its live chart; citation dropped (triage
direction 5852548444) | a joined report's 'chart' removed from its
blocks and refused on the container (ADR-0049 enforce-or-remove: the
joined renderer draws each block as a table and never read either, so
the chart parsed and nothing was plotted; a non-joined report keeps its
live 'chart') |
| `form-layout-inline-grid-to-vertical` (:13742) | objectstack-ai#20221 | retire the
`inline` / `grid` arms: multi-column already exists as `columns` and
`inline` is not a record-form layout; citation dropped, the sentence
already said it (triage direction 5855767378) | form 'layout' arms
'inline' and 'grid' rewritten to 'vertical' (ADR-0049 — no renderer ever
gave either a behaviour of its own: every form presentation folded both
to 'vertical'. Multi-column is 'columns', honoured under either layout,
and is left untouched) |
| `currency-config-precision-removed` (:13898) | objectstack-ai#19992 | remove
`currencyConfig.precision`: a currency's decimal places are the
currency's, not a setting; citation dropped, the sentence already said
it (triage 5817146460 (ruling 乙 on objectstack-ai#19910 it executes)) | currency field
key 'currencyConfig.precision' removed (ADR-0049 — no renderer or
runtime ever read it: an amount's decimal places are its currency's ISO
4217 minor unit, derived from the currency itself. Its ISO 4217
contradiction check and the default `2` baked into parse output went
with it; the field-level `precision` is a total digit count and is
untouched) |
| `permission-rls-tags-removed` (:14002) | objectstack-ai#20321 | RETIRE by the
maintainer's criterion (no mainstream platform has the capability);
citation dropped, the sentence already said it (triage verdict
5860425529) | RLS-policy key 'tags' removed (ADR-0049 — nothing ever
read a policy's tags and no mainstream platform tags a row-level policy;
dropping it changes no access decision) |
| `flow-decision-mode-inclusive-explicit` (:14141) | objectstack-ai#15429 | align with
mainstream engines: an edge-branched decision is exclusive (first
match), and taking every true edge must be declared (`mode:
'inclusive'`); the migration writes it explicitly for existing nodes so
authored behaviour is unchanged (ruling C narrows that promise to
sources and artifacts) (ruling 5793803317; ruling C 5863827385) |
edge-branched decision with two or more conditioned out-edges and no
`mode`: `mode: 'inclusive'` written explicitly (the traversal became
exclusive, first match in declaration order, as mainstream engines treat
a decision, and taking every true edge must now be declared; the key
keeps the every-true-edge behaviour those nodes had, and the author
deletes it where the branches partition) |

No site was left in place as unclear; `open_questions` is empty.

## Text only (A4)

Stage 3's AST-skeleton plus string-text tool (`skeleton.cjs`, TypeScript
6.0.3; stage 4's copy with only its TypeScript load path changed to this
worktree, md5 `3b10ec8a7e6284f19def54d35f001698` →
`32b4630d7d0a532ee26239319269fe91`). Leg 1 compares an AST skeleton with
every string's text masked (a `+` chain of string operands reads as one
string, so re-wrapping is invisible); leg 2 compares the text of every
string group, requiring each changed group to carry a tracker id before
and none after, and every other group byte-identical.

- `registry.ts`, base `e901c27449` vs the committed copy at
`9c1d040145`: **1 of 1 SAME**, exit 0 — 62656 tokens both sides, 4955
string groups, 35 changed, every changed group carried an id before and
carries none after; parse diagnostics 0/0.
- Controls on scratch copies of the head file, each mutation counted on
disk first (anchor hits 1, replacement present 1, anchor left 0):
`renameFlowConfigAliases` renamed → DIFF exit 1; `!==` flipped to `===`
→ DIFF exit 1; one summary re-split into two `+` operands → SAME, 0
groups changed, exit 0; the `hook.timeout` surface string (never carried
an id) changed → skeleton SAME, text leg VIOLATION, exit 1. No repo file
was mutated for the controls.
- The edits were applied by a script whose every anchor was asserted to
hit exactly once, inside its own conversion's summary line span, and
verified on disk after the write (36 anchors over 35 conversions). The
conversion-table extractor reads 35 summaries changed, `toMajor` /
`surface` / declaration unchanged on all 127, and 0 ids left in any
summary.

## Pins and quotes (A6)

No test asserts a summary, so no pin moves: nothing under `*.test.*`
reads `.summary` off a conversion (the only summary reads are
`spec-changes.ts` and `build-upgrade-guide.ts`), and every removed
id-bearing fragment was searched across the repo. The hits are other
files' own prose with their own citations (CHANGELOGs,
`migrations/registry.ts` rationale, docblocks, `liveness/*.json` notes,
test titles such as `permission.test.ts:278`, docs prose in
`content/docs/permissions/*.mdx`), not quotes of a summary.
`content/docs/**`: no quote of a changed summary. `skills/**`: none.

## Verification

All builds and tests through `scripts/pm/os-verify-lock.sh` (slot
`issue-20749-s5`), each `VERDICT command-exit 0`:

- `pnpm --filter @objectstack/spec build`, then `check:generated` on the
merged head `9a35e049e5`: "✓ All 15 generated artifacts are up to date".
- The package `test` script (`vitest run --project local
--maxWorkers=2`) on `9a35e049e5`: "Test Files 605 passed (605) / Tests
17904 passed | 1 todo (17905)".
- `test:repo`: the 15 repo-project files that read the conversion
registry, `spec-changes` or the guide, "Test Files 15 passed (15) /
Tests 221 passed (221)". NOT MEASURED:
`scripts/build-schemas-check-mode.test.ts` (88 cases at about 7 s each,
over the foreground cap; it reads only conversion surfaces, which the
proof shows unchanged) and the remaining repo-project files; reason:
wall clock on a shared box. CI runs them.
- `pnpm --filter @objectstack/spec run typecheck` on `9a35e049e5`: exit
0; "check:test-typecheck: OK — 52 file(s) / 246 error(s) / 135 pinned
signature(s) held".
- `pnpm turbo run build --concurrency=2 --filter=./packages/*
--filter=./packages/*/*`: "Tasks: 71 successful, 71 total", for the
gates that read built packages.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) at `9a35e049e5`
derives 79 commands; each ran with its exit code written to disk before
any pipe. Three first exited 3 (PREREQUISITE NOT MET:
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` need built packages) and exit 0 after the
build; the dist-reading gates were re-run after it too. `--ran`: "✓
dispatch-gates --ran: 79 derived famil(ies) accounted for — 79 run, 0
NOT-MEASURED".
- `pnpm check:doc-authoring` (self-test and run): exit 0, "17323
customer-facing string(s) across 1250 spec sources clean"; the
sibling-package ledger holds its baseline (`packages/spec` sits outside
it, so no ledger change). `pnpm check:nul-bytes`: exit 0, "no raw ASCII
control bytes".
- Changeset gates with this body as the `--event` payload:
`check-changeset-no-major.mjs --base origin/main --event` exit 0 ("✓
LEVEL AXIS: this PR declares clause-② `no`", declaration line `Clause-②:
no`); `check-partof-closing-keyword.mjs` with `PR_BODY` exit 0 ("no
Part-of/closing-keyword contradiction");
`check-adr-0087-registration.mjs --base origin/main` exit 0 ("adds no
declared-breaking changeset (1 non-breaking changeset(s) seen)");
`check-empty-changeset.mjs --base origin/main` exit 0;
`check-changeset-fixed.mjs` exit 0.
- ESLint, a proven narrowing: `eslint --no-inline-config --format json`
over the one changed TS file reads 1 file, 0 errors, 0 warnings; the
population is read from ESLint's own config (`calculateConfigForFile`
resolves it, `isPathIgnored` false); invariance: `eslint.config.mjs`
enables no type-aware linting (`parserOptions.project` /
`projectService` null for this file, its header at :327-328 says so), so
a string-text edit cannot move an untouched file's verdict. Repo-wide
`pnpm lint` is CI's.

## Acceptance notes

- `origin/main` was merged once (`9a35e049e5`, five commits: objectstack-ai#21539,
objectstack-ai#21473, objectstack-ai#21554, objectstack-ai#21556, objectstack-ai#21557; spec moved only in
`contracts/approval-service.ts` TSDoc); spec rebuilt, `check:generated`,
the spec test project, typecheck and the gate union re-ran on the merged
head. No os-regen deferral was recorded.
- Hot file: no open PR touches `conversions/registry.ts`,
`spec-changes.json` or the upgrade guide (all nine open PRs' file lists
read just before opening this one).
- Census after this PR (stage 3's instrument at `9a35e049e5`): non-test
160 → 125 messages, 358 → 318 ids; class (b) is empty. Left for the
later stages: class (c) conformance-case notes 58 messages / 68 ids (the
`objectstack-ai#5322` selector and the `objectstack-ai#8934` name pin move with their tests), class
(f) internal registry rationale 17 / 33, the test strings 1804 / 1920 in
425 files; `migrations/registry.ts` 50 / 217 stays with objectstack-ai#20234's stage
11. Word-form hits (an id spelled after "PR", "issue" and the like) stay
6, all outside this diff.
- Excluded, untouched: `migrations/registry.ts`, comments anywhere,
classes (c) and (f), test strings, the `.mjs` gate scripts. No gate is
added or loosened.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… metadata layer holds; layering can only narrow intake (objectstack-ai#21864)

Fixes objectstack-ai#21835

Clause-②: yes (widening)

Fixes a regression introduced after 17.6.0 (with objectstack-ai#21420); it should land
before 17.7.0 is cut.

## What

Per the rulings recorded on objectstack-ai#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.

- **Anonymous doors (`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 same `formViews` key, or the flattened config) or with the same
slug. Other views that share the public slug never close each other.
- **What counts as a withdrawal.** A sharing that keeps its `publicLink`
and sets `enabled: false` or `allowAnonymous: 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.
- **Write door (save and publish).** An org-scoped `view` save or draft
promotion in the organization the doors read is refused with `403
NOT_OVERRIDABLE` when 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 renamed
`formViews` keys, `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.
- **Package-shipped forms.** A package artifact is part of the env-wide
definition, not a separate layer. A package artifact parsed by the stack
schema (strict `defineStack`, the default) carries the schema's default
`enabled: false`, so a shipped form that keeps its link without
switching `enabled` on 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.
- **Known limit: packages and names.** A withdrawal of a view name
closes that name in every package: when two packages ship a view of the
same name, one package's withdrawal also closes the other package's form
of that name. It may over-close, never under-close. Per-package
precision is tracked in objectstack-ai#21934. A publish judges the draft it promotes
under the same package key (the stated one, else the resolved draft
row's own), so with two packages holding a draft of the same view in one
organization, each draft is judged on its own publish.
- **Intentional reversal.** The earlier behaviour in which an
organization overlay re-published a form the package had withdrawn is
reversed. A form with no env-wide word on it stays
organization-publishable (objectstack-ai#21420), and the objectstack-ai#21473 anchors and objectstack-ai#21566
field allowlist are unchanged.
- **Public surface:** `@objectstack/metadata-core` adds one export,
`anonymousFormIntakeWithdrawnIn` (`minor`). `@objectstack/rest` and
`@objectstack/metadata-protocol` are `patch`.
- **Known limit (ruled to stay as is).** The doors match by served item
name, and the write door runs only on an org-scoped save or publish. An
organization overlay stored before the env-wide withdrawal, or restored
by rollback or commit revert, can still be served if it keeps the form
open under a different key or slot than the env-wide definition.
Withdrawing the form in that overlay closes it. Stated in the changeset
and the docs.

## 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):

- Round 6 at `7882eef683` (merged origin/main `9dce635337`, merge commit
`46d08189a7`): 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 at
`4d5f6c4e61`; 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 at `7882eef683`, reconciled with `dispatch-gates
--ran`; `dispatch-gates --self-test` 1976 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 in `dist/`: 1 red in each of metadata-protocol, metadata-core and
rest; restored to HEAD (`git diff HEAD` empty), rebuilt, markers proved
absent.
- Round 5 at `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 with
`dispatch-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 HEAD` empty).
- Round 4 at `79b847042d` (targeted): metadata-core
`anonymous-form-intake.test.ts` 39/39, metadata-protocol
`protocol.org-scoped-write-refused.test.ts` 41/41, rest
`public-form-withdrawal` + `public-form-intake-availability` 43/43.
Typecheck green for metadata-core and metadata-protocol. Docs and
changeset gates green. New pins: a package parsed `false` is 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.
- Typecheck green for all three and dogfood.
- Dogfood (real showcase boot): the layered-withdrawal suite 5/5
(including the re-save refusal) and the five sibling public-form suites
20/20.
- Coverage: two views sharing a slug do not close each other (one read,
with and without an organization, and across layers); a cleared link is
not a withdrawal; a parsed link-less sharing is not a withdrawal;
re-saving an already-open overlay is refused; a container-shaped save is
judged after expansion.
- Ablation (source set back to the base blobs, packages rebuilt): 3 / 4
/ 4 tests red across metadata-core / rest / metadata-protocol; restored
to HEAD.
- Gates: 92 of 95 derived run green; `check:skill-examples`,
`check:dual-build-cjs-loads` and `check:type-check-debt` are NOT
MEASURED locally (workspace-wide prerequisites) and left to CI.

## Acceptance notes

- The known limit above (an overlay stored before the withdrawal, or
restored by rollback or revert, with its form under a different key or
slot) is accepted per the ruling on objectstack-ai#21835. No provenance or new
protocol query was added.
- Two installed apps publishing the same slug is a separate concern
(slug collision), out of scope here.
- A package artifact loaded without the stack schema's parse (`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 objectstack-ai#21835).
- An objectql test double now answers the publish gate's draft-row read
(`protocol-publish-package-drafts.test.ts`); that is test-only.
- Maintainer ruling, 2026-10-06: 「撤掉跨包那一改,合并」. The cross-package skip is
removed; per-package precision is tracked in objectstack-ai#21934.
- This branch carries three dogfood files ported unchanged from objectstack-ai#21935
(`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 objectstack-ai#21935 lands.

---
_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants