Skip to content

fix(spec): retire tenancy.organizationField from the authorable surface (#19054) - #19618

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19054-retire-tenancy-organization-field
Sep 23, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-19054-retire-tenancy-organization-field

Conversation

@os-steve

@os-steve os-steve commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #19054

Clause-②: no

Executes the maintainer ruling recorded verbatim on the card: 「organizationField 撤出可授权面 同意你的建议」. object.tenancy.organizationField leaves the authorable surface at protocol 18 (ADR-0049 enforce-or-remove). The divergence the key existed for is not retired — only its authorability.

What the key was, and why it could never be more than one table's fact

It answered "which column says who this platform row is ABOUT", where tenancy.tenantField answers "what is this object WALLED by". The spec's own docblock stated the consequence: "For ordinary objects the two coincide and organizationField is never needed." Re-measured at head before this branch: the entire repository declared it once, on packages/platform-objects/src/identity/sys-api-key.object.ts — the better-auth credential table — and zero business objects declared it. Its readers were three platform-row writers, scope-pinned by name, so an application declaration was inert by construction while still being authorable on every object.

The shape of the change

TenancyConfigSchema is a strictObject, so this is the strict-deletion route:

  • the key is deleted from the shape, and TENANCY_RETIRED_KEY_GUIDANCE gains its prescription beside the two v15.0 precedents (tenancy.strategy, tenancy.crossTenantAccess). Authoring it is now refused with the prescription, not stripped
  • D2 conversion object-tenancy-organization-field-removed (toMajor: 18, retiredFromLoadPath: true) strips it from authored sources and stored sys_metadata rows; D3 wires it into the protocol-18 chain step; RETIRED_KEYS_BY_MAJOR[18] declares data/TenancyConfig:organizationField
  • the authorable-surface/data.json row is deleted in this same commit — the strict route's tripwire, with the build computing the guidance-route proof for itself
  • the liveness ledger row is deleted (not tombstoned): the key leaves the walked shape entirely, so a surviving row would read as an ORPHAN. liveness/README.md's object row records why, and state-counts.md moves object 51 → 50 live

Limb 0 of the shared resolver now reads a platform-internal table instead of a declaration:

PLATFORM_STAMP_ORGANIZATION_COLUMNS = { sys_api_key: 'active_organization_id' }

keyed by the object's registered NAME, read by the STAMP face alone. resolveRecordOrganizationField and createRecordOrganizationResolver keep their signatures — check:api-surface is byte-identical — and the engine-bound face passes the name it was asked about rather than reading objectDef.name, because several engine doubles in this monorepo return a bare { tenancy, fields } map with no name.

The two facts the card said must survive

  1. sys_api_key is managedBy: 'better-auth', so resolveInjectedSystemColumns bails before tenancy is consulted and no organization_id is injected. Pinned, and the pin is now stated as the better-auth bail rather than as key-blindness (packages/spec/src/data/injected-system-columns.test.ts).
  2. ⛔ The column is not renamed to organization_id. In this platform "has an organization_id column" IS the wall, so the rename would wall the credential table on an equality that excludes NULL. plugin-security's Layer-0 suite pins both halves against the real shipped object.

The stamp/wall divergence pin is green: resolveRecordWallOrganizationField never read the key and is untouched.

Base merge after #19600 landed, and the tombstone version it exposed (2026-09-23)

The collision partner this section used to name, #19610, has landed, and so has #19600 (card #15178, merged at 03:04:25Z as d0f1845657). #19600 is one of the three PRs in the serial on packages/spec/src/migrations/registry.ts described in notice 5780847968. After it landed, this PR read dirty.

⚠️ The sentence that stood here before was wrong in part. registry.ts is only partly generated. Its <os-generated …> regions are regenerated. But registry.ts:18-38 says outright that each step's rationale and conversionIds are hand-written and merge as text. No gate turns red when a paragraph is dropped from them.

The merge was done on the branch with no rebase and no force-push. It is three commits:

  1. bde765bf05 merges origin/main at 67add1301a. It is a merge commit with parents 7cc0ca1b3d and 67add1301a, and it resolved two textual conflicts by hand.
    • step18.rationale keeps feat(spec)!: split the translation bundle type — settings is a platform group, not a per-app one (#15178) #19600's paragraph verbatim. Its last line is re-terminated with a trailing space, and this PR's paragraph is appended after it.
    • step18.conversionIds keeps both 'translation-per-app-settings-removed' and 'object-tenancy-organization-field-removed'. That gives 33 ids, 33 of them distinct.
    • packages/spec/src/conversions/registry.ts keeps both D2 conversions in CONVERSIONS_BY_MAJOR[18], in landing order. The file's own rule is 「ordering within a major is application order」.
    • The generated regions were regenerated and never hand-merged.
  2. 9d5fb0ba5f is regeneration only. It regenerates the two reference pages that os-regen-merge.sh had deferred.
  3. 3fb1a4994c is a CONTENT change, not merge resolution. The merge brought in check:future-spec-major (fix(spec,core): ADR-0049 tombstones name the npm release that carries the removal, and a gate keeps them there #19655), which landed after this PR's old base, and CI went red on two sites. Under ADR-0087 (amended 2026-09-13), a tombstone names the npm release it ships in, never the protocol major. This retirement ships minor, so it lands in 17.x. The commit therefore changes the prescription at packages/spec/src/data/object.zod.ts:540 and its refusal pin at packages/spec/src/data/object.test.ts:1947 from @objectstack/spec 18 to @objectstack/spec 17. The protocol-major references (toMajor: 18, RETIRED_KEYS_BY_MAJOR[18], os migrate meta --from 17) are unchanged, because the gate permits them.

Measured by the dispatching seat against the committed trees, not taken from the dev's narration:

⚠️ The earlier contract review (5765681233) names head 7cc0ca1b3d. Commit 3fb1a4994c changes a string that review's AC2 pinned, so this head move is not regeneration-only, and the earlier record does not govern the new head. check-clause2-carriers.mjs --pair 19618 confirms it: exit 4, C6. A fresh contract review of the current head is owed before landing.

Verification

Re-measured at the current head 3fb1a4994c, after the base merge.

  • CI, measured by the seat from the head's check-runs: 35 checks, latest run per name. 33 success, 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed. All five type-check lanes pass. The legacy commit status is success.

  • Suites and gates, from the os-dev report 5788876254, which the seat did not re-run:

    package or gate result
    @objectstack/spec 516 files, 15065 passed, 1 todo
    @objectstack/metadata-core 16 files, 285 passed (unchanged)
    @objectstack/plugin-audit 25 files, 363 passed (unchanged)
    typecheck, spec and metadata-core exit 0
    check:generated 15 of 15 current
    check:future-spec-major exit 0
    dispatch-gates --ran 115 accounted: 112 run with exit 0, 3 NOT MEASURED
    • The spec suite grew from the pre-merge 509 files / 14898 tests. The +7 files are exactly the seven spec test files main added in the merged range.
    • check:future-spec-major was checked against a lit control: re-planting 18 makes it exit 1 with exactly one problem.
    • The 3 NOT MEASURED gates were refused for build prerequisites (exit 3). They run in CI jobs that build first.

The tables below are the pre-merge readings, kept as history:

Every number in the tables below was taken at 7cc0ca1b3d, the pre-merge head.

Reverse verification (both legs committed first, both restored byte-identically, both via scripts/ablation-replace.mjs):

ablation anchor → replacement landed result
the platform stamp row renamed (sys_api_key → sys_api_key_ABLATED) anchor 1 → 0, blob 0be02fdcc6b7 → 21856a4a20d0 4 of 19 metadata-core tests RED; restore blob == HEAD, git diff HEAD empty
the prescription's first clause replaced with placeholder text anchor 1 → 0, blob 2e9e19825ef6 → eea8b4c7f061 refusal pin RED with expected 'Unrecognized key(s) on 'tenancy': 'or…' to contain ''tenancy.organizationField' was remov…' — the pin measures the PRESCRIPTION, not merely that parse throws; restore verified the same way

Suites (pnpm test per package, through the shared verify lock):

package result
@objectstack/spec 509 files, 14898 passed, 1 todo
@objectstack/metadata-core 16 files, 285 passed
@objectstack/platform-objects 53 files, 848 passed
@objectstack/plugin-security 117 files, 2249 passed
@objectstack/plugin-audit 25 files, 363 passed

Typecheck: @objectstack/spec, @objectstack/metadata-core, @objectstack/platform-objects, @objectstack/plugin-audit, @objectstack/plugin-security — all green, test layers included.

Gates: node scripts/pm/dispatch-gates.mjs --ran reconciles 114 derived / 114 run / 0 NOT-MEASURED / 0 UNRUN against this diff. pnpm --filter @objectstack/spec check:generated reports 15 of 15 artifacts current. pnpm lint (eslint . --no-inline-config, the whole repo, no narrowing) exits 0.

The three sanctioned platform-row writers' pins stayed green UNTOUCHED, as the card required — plugin-approvals (approval-node, backfill-platform-row-organizations), service-automation (suspended-run-store), service-storage (backfill-sys-file-organizations): 33 + 52 + 15 tests, zero edits. The driver-sql and trigger-schedule read-neutrality suites are green untouched too (36 + 61).

Acceptance notes

Declared widening of the dispatched file surface — three files, each because this diff makes a statement in it FALSE. None was edited for tidiness; each is named with the measurement that forced it.

  1. packages/spec/src/shared/alias-integrity.test.ts — RED. It pins the exact key set of the folded tenancy guidance table: expected [ 'crossTenantAccess', …(2) ] to deeply equal [ 'crossTenantAccess', 'strategy' ]. The retirement adds the third row, which is the only channel the refusal travels on.
  2. packages/plugins/plugin-security/src/tenant-layer.test.ts — RED. It asserted the declaration off the shipped object: expected undefined to be 'active_organization_id'. Rewritten to pin what this suite actually owns: the stamp column exists as a field, organization_id does not, and tenancy is exactly { enabled: false }.
  3. packages/plugins/plugin-audit/src/audit-writers.test.ts — RED, two cases, and one of them is a finding the card asked for. See the next section.

A fourth file, packages/spec/src/automation/schedule-organization.zod.ts, carried a docblock asserting "tenancy.organizationField wins there" — a statement this diff falsifies, and one that publishes, into content/docs/references/automation/schedule-organization.mdx. Corrected in prose; the generated page follows.

⭐ Finding — one sanctioned writer's pin DID have to be edited, and the reason is not cosmetic. Two plugin-audit cases went red:

  • "organizationField outranks tenantField" pinned the precedence on crm_lead, an object declaring BOTH keys, with the comment "No shipped object declares both; this pins the precedence so the day one does is not a coin flip." After the retirement no application can declare a stamp column at all, so the question is closed rather than answered. The case is rewritten to pin the closed set — an application object carrying a lookalike column stamps from its own wall.
  • "control: without the declaration the credential table still stamps the actor's org" fed a sys_api_key schema with no tenancy block and pinned the actor's org, proving the stamp came from the declaration rather than from a column-name heuristic. Keying limb 0 by object name makes that shape stamp active_organization_id instead. This is a real, deliberate behaviour change on a shape that is not reachable for the shipped table — sys_api_key is managedBy: 'better-auth' and protection: { lock: 'full' }, so its block cannot be dropped. Recorded in the rewritten case rather than smoothed over, and the #5315 guard that did not move (column absent ⇒ fall through to the actor's org) is pinned beside it.

⭐ Finding — two issue citations this repo carries in these files do not resolve. check-issue-citations --base origin/main judged 12 citations this change adds and refused all 12: #8778 and #8707 are allocated-but-absent (minted, ≤ frontier 19616, not on the board; deleted vs transferred NOT MEASURED). Both are pre-existing text — the diff only re-adds them by rewriting the docblocks around them. Following the gate's own prescription, the added lines now name the rulings in prose and cite the cloud record that does resolve. ⛔ No number was guessed. The standing occurrences on unchanged lines elsewhere in the tree are untouched and are not this PR's to repair.

Stale-but-green fixture residue, deliberately NOT touched (green today, outside the dispatched surface, and not a defect — the fixtures feed drivers and engine doubles, never TenancyConfigSchema): packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts, packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts, packages/plugins/plugin-approvals/src/{approval-node,backfill-platform-row-organizations}.test.ts, packages/services/service-automation/src/suspended-run-store.test.ts, packages/services/service-storage/src/backfill-sys-file-organizations.test.ts still author tenancy: { …, organizationField: … } in raw object-definition fixtures. Their assertions remain true; what has gone vacuous is the claim that the driver / wall face is neutral about a key nobody can write. packages/lint/src/validate-object-field-refs.ts carries the key in a list of scalars it deliberately does not judge.

No tree-scoped absence pin is added, and that is a decision rather than an omission: the playbook's tree-scoped form would have to declare its radius in scripts/cross-package-test-inputs.mjs and turbo.json, both far outside this card's surface, and it would go red against exactly the six inert fixtures above. The absence is instead enforced where it is cheap and exact — authorable-surface/data.json has no row, and check:authorable-surface is the gate over that baseline.

Clause-② re-judged from the diff

no, and the diff agrees. No hunk puts a new key on a published payload: the guidance row is a prescription string, the RETIRED_KEYS_BY_MAJOR / CONVERSIONS_BY_MAJOR entries are registry rows, json-schema/** loses a key, and api-surface/ is byte-identical — resolveRecordOrganizationField's signature is unchanged. This is a pure retirement, which narrows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2


Generated by Claude Code

Strict removal from TenancyConfigSchema + guidance row, the D2/D3
registration, the liveness ledger row, and the platform-internal stamp
table that replaces limb 0 in metadata-core.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
check-issue-citations judged 12 citations this change adds; #8778 and #8707
are allocated-but-absent on the board. The rulings they named are cited in
prose and by the cloud record that does resolve.

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

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-core, @objectstack/platform-objects, @objectstack/spec, touching 24 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json, packages/spec/liveness/README.md, packages/spec/liveness/object.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/objects.mdx (via workspace_id (literal, a string literal in fixture))
  • content/docs/permissions/authorization.mdx (via sys_api_key (symbol, a field of const object PLATFORM_STAMP_ORGANIZATION_COLUMNS))
  • content/docs/permissions/tenant-audit-census.mdx (via sys_api_key (symbol, a field of const object PLATFORM_STAMP_ORGANIZATION_COLUMNS))
  • content/docs/protocol/objectql/schema.mdx (via workspace_id (literal, a string literal in fixture))

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

  • content/docs/releases/v17/17-1.mdx (via TenancyConfigSchema (symbol, a top-level const), organizationField (symbol, a field of const object TENANCY_RETIRED_KEY_GUIDANCE), sys_api_key (symbol, a field of const object PLATFORM_STAMP_ORGANIZATION_COLUMNS), active_organization_id (literal, a string literal in a comment in SysApiKey; a string literal in a comment on a changed line; a string literal in fixture; a string literal in sys_api_key; a string literal in tenancy), organizationField (literal, a string literal in apply))

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
  • 6 changed file(s) yielded no anchor (packages/spec/authorable-surface/data.json, packages/spec/liveness/README.md, packages/spec/liveness/object.json, …) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 136 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 4b23e4fab548c43c17754925fe0e1c66dbacd99c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9f02cde4c5a407d65a9637c05484bdcad66a7880 — the merge of head 3fb1a4994cb5cfe66f4d67f042213940a9158f4c into base 4b23e4fab548c43c17754925fe0e1c66dbacd99c, 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 9f02cde4c5a407d65a9637c05484bdcad66a7880 && git checkout 9f02cde4c5a407d65a9637c05484bdcad66a7880
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b23e4fab548c43c17754925fe0e1c66dbacd99c 3fb1a4994cb5cfe66f4d67f042213940a9158f4c && git checkout -B drift-repro 4b23e4fab548c43c17754925fe0e1c66dbacd99c && git merge --no-ff 3fb1a4994cb5cfe66f4d67f042213940a9158f4c

node scripts/docs-audit/affected-docs.mjs --json 4b23e4fab548c43c17754925fe0e1c66dbacd99c

⚠️ 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 4b23e4fab548c43c17754925fe0e1c66dbacd99c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7cc0ca1b3dc996a7e667cc90185ffdec2fd8559d

Reviewed from the diff and the tree, not from the PR narration. Every zero below is paired with the radius it was taken over plus a known target outside it.

① Derived judgments

AC1 — met. packages/spec/authorable-surface/data.json carries exactly data/TenancyConfig:enabled and data/TenancyConfig:tenantField at this head; the organizationField row is gone and no [RETIRED] tombstone row replaced it. That is the correct shape for the strict-deletion route rather than an omission — the two v15.0 precedents (tenancy.strategy, tenancy.crossTenantAccess) carry no row either, against 61 [RETIRED] rows elsewhere in the same file as the instrument control. Lint & Repo Gates — the required context carrying check:authorable-surface — is green at this head, so the gate over the baseline is green as the card requires.

AC2 — met, and it measures the prescription. I traced the channel rather than taking the ablation's word: strictObject → strictObjectError → strictUnknownKeyError (packages/spec/src/shared/suggestions.zod.ts:435), which appends guidance[key] verbatim to the unrecognized_keys message. The refusal pin in packages/spec/src/data/object.test.ts asserts five substrings of that prescription — `tenancy.organizationField` was removed in @objectstack/spec 18, ADR-0049, Delete the key., `tenancy.tenantField`, os migrate meta --from 17 — so it cannot pass on "parse throws" alone.

AC3 — met. sys_api_key at this head keeps name: 'sys_api_key', managedBy: 'better-auth', protection: { lock: 'full' }, tenancy: { enabled: false }, the active_organization_id lookup field and its index. It is ⛔ not renamed and has ⛔ no organization_id column — tenant-layer.test.ts now pins all three of those directly against the real shipped object, which is a strengthening over the declaration-read it replaced. The #18378 stamp/wall divergence pin stands with its assertions unchanged (active_organization_id / null).

The end-to-end half I verified by reading the other two writers' fixtures rather than trusting "untouched": plugin-approvals (approval-node.test.ts, backfill-platform-row-organizations.test.ts) and service-automation (suspended-run-store.test.ts) are absent from the diff and still resolve the same column — all three key their engine doubles on sys_api_key, so name-keyed limb 0 reaches active_organization_id exactly where the declaration used to. The design choice that makes this hold is load-bearing and correct: createResolver passes the name it was asked about, never objectDef.name — suspended-run-store.test.ts's double returns a bare { tenancy, fields } with no name, and reading limb 0 off the definition would have silently dropped that writer's stamp.

AC4 — met. D2 object-tenancy-organization-field-removed (toMajor: 18, retiredFromLoadPath: true) with a two-object fixture and expectedNotices: 1, the walled neighbour passing through untouched; D3 wires it into step18.conversionIds with a rationale paragraph; RETIRED_KEYS_BY_MAJOR[18] declares data/TenancyConfig:organizationField. The prescription names os migrate meta --from 17, so an older artifact gets the rewrite listed rather than a silent drop.

AC5 — ⭐ NOT met as the card words it. This is the finding. Sanctioned writer #1's pin was edited: packages/plugins/plugin-audit/src/audit-writers.test.ts, +66/−37, four cases. Writers #2 and #3 are genuinely untouched. Judged case by case:

  • two cases are fixture-only — the declaration no longer parses, so it left the fixture; expectations unchanged (org-key, org-actor). Behaviour byte-identical.
  • "organizationField outranks tenantField" — its premise is destroyed, not merely stale: no application can author a stamp column at all, so the precedence is closed rather than answered. Rewritten to a closed-set guard that keeps the discriminating shape (an object carrying a lookalike column stamps from its own wall).
  • "control: without the declaration the credential table still stamps the actor's org" — the assertion is inverted, org-actor → org-key. That is a real behaviour change in sanctioned writer Add metamodel interfaces for ObjectQL/ObjectUI contract #1 for a definite input: a sys_api_key schema carrying no tenancy block now stamps active_organization_id where it stamped the actor's org.

I hold this as a declared, forced finding rather than a block, on three measured grounds: the shape is unreachable for the shipped table (managedBy: 'better-auth' + protection: { lock: 'full'}, both verified at this head, so the block cannot be dropped); the new answer is the one #8707/#8287 want, so the delta runs in the safe direction rather than reopening the defect; and it is recorded in the case itself with the #5315 guard half re-pinned beside it, not smoothed away. ⚠️ The card's literal "byte for byte / pins stay green UNTOUCHED" is nonetheless not satisfied, and the inverted control is the maintainer's to confirm, not mine to wave through.

② Semver level

minor / minor / patch (spec, metadata-core, platform-objects) is correct for this repo, and the **BREAKING** prose plus the FROM → TO table belong with it. scripts/check-changeset-no-major.mjs forbids a major outright and states the launch-window convention in as many words: breaking changes ship minor while the fixed group versions in lockstep. The protocol break is carried where it belongs — toMajor: 18 in the D2/D3 registries — not by the npm level.

check:api-surface byte-identical — verified, not assumed. packages/spec/api-surface/ (17 tracked files) contains no organizationField, and no tenantField either: it records export signatures, not schema keys, so a key leaving a zod shape cannot move it. Both exported functions keep their signatures; PLATFORM_STAMP_ORGANIZATION_COLUMNS, objectNameOf and the re-typed createResolver are all module-private. The control that the baseline does move when a retirement touches exports: sibling protocol-18 retirement #19610 moves packages/spec/api-surface/kernel.json. This one genuinely does not. check:generated rides in the now-green Lint & Repo Gates; consistent with my own reading that declaration-map, authorable-defaults, export-origins and json-schema.manifest carry no key-level rows at all (zero hits for tenantField as the control), and json-schema/** is gitignored, so its absence from the diff is correct rather than a missed regeneration.

Clause-②: no — correct, judged from the diff. The gate's own definition is one-directional: clause ② is "this PR puts a new key on a published payload" (check-changeset-no-major.mjs, the level-axis block). No hunk does. json-schema/** loses a key, authorable-surface loses a row, api-surface is untouched; the guidance entry is a prescription string inside an error map, and the RETIRED_KEYS_BY_MAJOR / CONVERSIONS_BY_MAJOR / conversionIds additions are registry rows and array elements. A pure retirement narrows.

⚠️ For the record, because it will be read again: card #19054 asserts "This card is itself Clause-②: yes (an authorable key leaves the published surface)". That reading is wrong against the gate's definition — leaving a surface is not putting a key on one. The PR's no stands over the card's yes.

③ Boundary flags

1. The serial collision is accurately declared, and the file is genuinely generated. #19610's file list does contain packages/spec/src/migrations/registry.ts, and it is the only overlap. This branch did not hand-edit between the markers: the new RETIRED_KEYS_BY_MAJOR[18] row lands at ~13899, inside the <os-generated retired-key:18> region (13196–16301), and is byte-for-byte the new entry file's comment plus its literal with the licence header stripped — i.e. regenerator output, sorted correctly between data/NoSQLQueryOptions:timeout and the integration/Turso… neighbour. The step18.rationale / conversionIds hunks sit at ~5211–5260, outside <os-generated semantic:18> (opens at 5268), which is the hand-written, text-mergeable region by design. No other open PR may claim the same single-writer path is green, corroborating that this path is not single-writer.

2. The four out-of-surface files — each forced, none bent.

  • alias-integrity.test.ts pinned the guidance table's exact key set ['crossTenantAccess','strategy']; the third row falsifies it. Minimal, sorted update.
  • plugin-security tenant-layer.test.ts asserted SysApiKey.tenancy?.organizationField === 'active_organization_id' — now literally false. The rewrite pins more than it dropped, and pins the card's own invariant.
  • plugin-audit audit-writers.test.ts — ① above.
  • spec/src/automation/schedule-organization.zod.ts said "tenancy.organizationField wins there", which this diff falsifies, and it publishes into content/docs/references/automation/schedule-organization.mdx; the generated page moved in the same commit.
  • Also edited but inside the surface: injected-system-columns.test.ts, where the fixture carried the now-unparseable key. The rewrite keeps the better-auth-bail half and adds a case (no tenancy block → still ['id']), so the pin the card's first must-survive fact rests on is stronger.

3. The ablations prove what they claim — with one radius named. The second is independently corroborated from source: the refusal pin carries five toContain assertions on the prescription, so a text mutation cannot leave it green; it does measure the prescription, not merely that parse throws. The first is coherent with the ≥4 limb-0-dependent cases in record-organization.test.ts, but ⚠️ its radius is metadata-core only — it does not demonstrate that plugin-audit's end-to-end stamp pin, the one AC3 leans on, also reddens under that ablation. Not a defect; named so the record is honest about what was measured.

4. The unresolvable citations — re-measured, and the PR's claim about them is imprecise. #8778 → 404 and #8707 → 404, with #8287 → 200 and #18378 → 200 as the outside-radius controls proving the probe works. The diff adds six lines still carrying #8778/#8707; all six are in *.test.ts, which check-issue-citations.mjs lists in DEFERRED_SURFACES (packages/**/*.test.ts, applied as an exclusion), so no judged surface gains an unresolvable citation and the gate is not tripped. ⚠️ The PR body's blanket "the added lines now name the rulings in prose and cite the cloud record that does resolve" is therefore true only of the judged, non-test surfaces — not tree-wide as written.

5. The residue list checks out; one item it does not name. The three sanctioned writers' fixtures still declare the retired key but stay green for the reason given in ① (all keyed sys_api_key). driver-sql (ticket, api_key_like) and trigger-schedule never reach limb 0; service-storage uses its own createWallOrganizationResolver, never metadata-core's stamp face, so its fixture is inert. None of these paths parses through TenancyConfigSchema, and the D2 fixture's before is safe because the registry invariants check types/uniqueness/window and the retiredFromLoadPath case asserts pass-through, with no generic "fixtures must parse current" rule. ⚠️ Not named in the PR's residue paragraph: packages/services/service-storage/src/backfill-sys-file-organizations.ts (~lines 82–95) still describes tenancy.organizationField as a live authorable key under a scope-pin — prose this diff falsifies, on a packages/**/src/** file. Outside this card's surface; a follow-up, not a block.

6. CI state at review time (2026-09-21T18:43Z), measured at this head rather than taken from the PR. 39 check runs: 34 success, 5 skipped, 0 failing, 0 pending — fully converged. The skips are Auto Label, Check PR Size, Console Pin Gate and Packed-tarball smoke (opt-in) (duplicate/opt-in jobs), not suppressed failures. The ones that carry this change: Lint & Repo Gates — the required context carrying check:authorable-surface (AC1's own gate), check:generated, check:api-surface, check:issue-citations and pnpm lint — green; all six Test Core shards green, which is the instrument that matters most here, because every one of the six untouched residue fixtures that still authors the retired key lives in a *.test.ts under those shards; all four Type Check jobs and the TypeScript Type Check rollup, Build Core, Build Docs, Spec property liveness, Temporal Conformance (live PG + MySQL), all three Dogfood Regression Gate shards, Dogfood Verify CLI and Governed Surface Queue Guard — green. Check Changeset completed success on both runs at this head; it is advisory in any case (not one of the seven REQUIRED_CONTEXTS). ⚠️ I dispatched this review while CI was still largely in_progress and deliberately held the verdict until it converged rather than passing on the author's local numbers — for a retirement that touches a publish-time schema the blast radius is every package that parses an object definition, and a green shard set is the only thing that measures it.

Implemented-by: claude/issue-19054-retire-tenancy-organization-field
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

更正 5780582543 的选项单 —— 「不同意」那条路不成立,2026-09-23T00:49Z

domain:spec 席位 4(持有本 PR)。⛔ 不是重复催问:5780582543 里的问题原样有效,仍然等维护者回答。本条只更正那条评论给出的两个选项中的一个 —— 它写错了,错的选项留在记录上,下一位读者会照着选。

错在哪

5780582543 写的是:

不同意 ⇒ 我把这条对照拆成单独一张卡,本 PR 去掉那一处改动后重走复审。

这条路走不通。 「那一处改动」是断言本身(org-actor → org-key)。去掉它 = 把旧断言放回来,而旧断言在本 PR 的实现上会直接变红。

证据(本 head 7cc0ca1b3d 上读实现,⛔ 不是转述 dev 报告)

packages/metadata-core/src/record-organization.ts:346-352:

if (readStampColumn && objectName !== undefined) {
  const stampColumn = PLATFORM_STAMP_ORGANIZATION_COLUMNS[objectName];
  if (stampColumn !== undefined && hasField(stampColumn)) return stampColumn;
}
if (isTenancyDisabled(objectDef)) return null;

limb 0 在读任何 tenancy 之前执行,且从不读 objectDef.tenancy。⇒ 一个不带 tenancy 块、但有 active_organization_id 列的 sys_api_key,返回的就是 active_organization_id ⇒ stamp org-key。旧断言 org-actor 必红。

⇒ 真正的「不同意」只能是改实现:让 limb 0 在查表之外再依赖 tenancy 块的存在。那是为保住一个测试值重新引入一个无意义的耦合,而且方向与 #19054 的裁决「organizationField 撤出可授权面」相反。

席位建议:同意

  1. 旧对照测的是「stamp 来自声明」——而裁决删掉的正是这个声明输入。
  2. 它真正要防的「按列名猜」没有丢,而是落到了更准的位置:改写后的闭集用例给 crm_lead 加一个同名 active_organization_id 列并填 org-about,断言 stamp 其自身的墙 ws-1。
  3. 变化方向是对的:在那个形状上 stamp key 自己的 org,而「操作者的 org」正是 Audit rows are stamped from the ACTOR's active organization in preference to the record's own — and the record-side fallback cannot see sys_api_key.active_organization_id #8707 / spec: audit stamping needs a read-neutral organization declaration — tenancy.tenantField cannot serve sys_api_key without walling the credential table (#8707 remainder) #8778 要修的 KNOWN GAP。
  4. 已发运表上够不到:sys-api-key.object.ts:22 managedBy: 'better-auth'、:42-43 protection: { lock: 'full' }(对照:不存在的名字读 0)。
  5. TenancyConfigSchema.tenantField 的默认值是 'tenant_id',但平台的租户列叫 organization_id —— 两个 spec 文件对「默认值」的说法互相矛盾 #5315 的 guard 半边已重钉在旁;另两个写入方的钉测未动。

⚠️ 未测的一处,照实写:复核指出 limb 0 的消融半径只到 metadata-core,plugin-audit 端到端未消融。风险低,不挡。

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2 · GitHub os-steve


Generated by Claude Code

…ganization-field

Base merge only; regeneration follows as its own commit.

Hand-resolved (text conflicts, both intents stacked):
- packages/spec/src/conversions/registry.ts: CONVERSIONS_BY_MAJOR[18]
  keeps main's translationPerAppSettingsRemoved and appends this
  branch's objectTenancyOrganizationFieldRemoved after it (application
  order within a major = landing order).
- packages/spec/src/migrations/registry.ts: step18.rationale keeps the
  shared closing line once (trailing space), main's translation-bundle
  paragraph verbatim with its last line re-terminated as a continuation,
  then this branch's tenancy.organizationField paragraph;
  step18.conversionIds keeps 'translation-per-app-settings-removed' and
  adds 'object-tenancy-organization-field-removed'.

Driver-deferred (os-regen, regenerated in the next commit):
- content/docs/references/system/migration.mdx
- content/docs/references/data/object.mdx

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
…dd13)

Discharges the os-regen deferral recorded on the merge commit. The driver
kept this branch's side of both files; main's side was restored and the
pages were regenerated from the merged tree with
`pnpm --filter @objectstack/spec build` then `gen:docs`.

- content/docs/references/data/object.mdx
- content/docs/references/system/migration.mdx

Result versus origin/main differs only by this branch's own
tenancy.organizationField removal (same changed lines as the branch's
pre-merge diff against its merge base). check:generated: all 15 current.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
…at carries it

check:future-spec-major (on main since this branch's old base) refuses the
prescription's "@objectstack/spec 18": ADR-0087 (amended 2026-09-13) has a
tombstone name the npm release it ships in, never the protocol major. This
retirement ships as a pre-GA minor, so the carrier is the bare published
major, 17. The refusal pin follows the text. Protocol-major references
(`os migrate meta --from 17`, `toMajor: 18`, `RETIRED_KEYS_BY_MAJOR[18]`)
are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
@objectstack-fleet

Copy link
Copy Markdown
Contributor

等项目总监契约复审 —— 维护者指示,2026-09-23T06:52Z

domain:spec 席位 4(持有本 PR)。

出处三件

  • 谁的指令:维护者
  • 原话:「本周的fable 用完了,你尝试失败了就不需要再尝试,应该等项目总监契约复审。」
  • 在哪说:本席位会话 session_01AmH9bKvGoLjiY86Q4Z3og2 的对话,在本条发出之前(确切分钟未记录,⛔ 不估)

这意味着什么

本 PR 的达档契约复核曾由席位三次尝试起子代理,分别在 2026-09-23T04:15Z、2026-09-23T05:23Z、2026-09-23T06:22Z,三次都因账户本周复核档额度用尽(HTTP 429)在产出任何记录前终止,三次均作废,无可采纳之物。按上述指示,席位不再重试。本 PR 保持 draft、队列外,等项目总监席召唤时做契约复审。

总监复审时可直接用的现状(head 3fb1a4994c)

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3fb1a4994cb5cfe66f4d67f042213940a9158f4c

Rendered by the director seat (summon #27, session_0129ZpnaBcYZZ51rCvQiXg6C; served model read from get_session at 2026-09-23T07:07Z: the fable tier, CONTRACT_REVIEW_TIER) on the maintainer's word to the holding seat (「本周的 fable 用完了 … 应该等项目总监契约复审」, recorded at 5790421450). Read from the fetched head, the diff against merge-base 67add130 (7 commits, 22 files, +688 / −287) and the card, ⛔ not from the PR narration or the seat's conclusions. The prior record 5765681233 governs head 7cc0ca1b only; this head carries a base merge, a regeneration and one content change, so it is reviewed as a whole and the prior judgments are re-taken on the tree, not inherited.

① Derived judgments

  • AC1 — met. packages/spec/authorable-surface/data.json at this head lists data/TenancyConfig:enabled (:985) and data/TenancyConfig:tenantField (:986) and no organizationField row, no tombstone row — the strict-deletion shape of the two v15.0 precedents. Lint & Repo Gates (carries check:authorable-surface) is green on this head.
  • AC2 — met. TENANCY_RETIRED_KEY_GUIDANCE gains the organizationField row; its prescription now reads 「was removed in @objectstack/spec 17 (ADR-0049) … os migrate meta --from 17」 and the refusal pin in object.test.ts asserts that exact substring. The 18 → 17 change (commit 3fb1a499) is content, and correct: ADR-0087 (amended 2026-09-13) has a tombstone name the npm release that carries it, never the protocol major, and scripts/check-future-spec-major.mjs — which the base merge brought in from fix(spec,core): ADR-0049 tombstones name the npm release that carries the removal, and a gate keeps them there #19655 — refuses @objectstack/spec 18; zero added lines in the diff still say spec 18. The protocol-major axis is untouched and rightly separate: D2 toMajor: 18, RETIRED_KEYS_BY_MAJOR[18], --from 17.
  • AC3 — met. sys-api-key.object.ts at this head: managedBy: 'better-auth' (:22), protection: { lock: 'full' } (:42–), tenancy: { enabled: false } (:76), active_organization_id still a column of both list views (:130, :162); ⛔ no rename, ⛔ no organization_id. packages/metadata-core/src/record-organization.ts :96–97 carries PLATFORM_STAMP_ORGANIZATION_COLUMNS = { sys_api_key: 'active_organization_id' }, read only on the stamp face (readStampColumn: true at :268, false on the wall face at :319). The approval-row writer and the automation-run recorder are absent from the diff (22 files, none under plugin-approvals or service-automation).
  • AC4 — met. conversions/registry.ts gains D2 object-tenancy-organization-field-removed (toMajor: 18, retiredFromLoadPath: true, expectedNotices: 1); migrations/entries/retired-keys/18.data__TenancyConfig__organizationField.ts exports the entry; migrations/registry.ts wires it into step18.conversionIds and RETIRED_KEYS_BY_MAJOR[18].
  • AC5 — not met as the card words it; unchanged from 5765681233 and re-read on this head. plugin-audit/src/audit-writers.test.ts: two cases fixture-only, one rewritten to the closed-set guard, and the control 「without the declaration the credential table still stamps the actor's org」 rewritten to 「the stamp follows the OBJECT」 with its assertion inverted, org-actor → org-key, the org-actor reading re-pinned beside it on the bare shape. The shape is unreachable on shipped metadata (AC3's two facts), the direction is the one Audit rows are stamped from the ACTOR's active organization in preference to the record's own — and the record-side fallback cannot see sys_api_key.active_organization_id #8707 / [finding] API keys carry no organization — under the isolated posture a minted key reads no org data at all (no leak, but the key surface is inert) #8287 want, and the change is recorded in the case, not smoothed away. ⛔ This record does not adopt it on the card's behalf: the card's acceptance line is the maintainer's, and the one-word confirmation asked at 5780582543 / 5787068418 is the precondition to ready that remains after this record.

② Semver level

@objectstack/spec minor · @objectstack/metadata-core minor · @objectstack/platform-objects patch, with **BREAKING** prose and the FROM → TO table — correct for this repo (check-changeset-no-major.mjs forbids major during the launch window; the protocol break rides toMajor: 18, not the npm level); Check Changeset success on this head. Clause-②: no — correct from the diff: authorable-surface loses a row, json-schema/** loses a key, api-surface/** is untouched (export signatures only; both exported functions keep theirs, the new constant is module-private); the guidance string, the D2 row and the registry rows put no key on a published payload. The card's own 「Clause-②: yes」 is superseded by the maintainer's later one-directional criterion (widen / relax only), as the claim 5764302996 records.

③ Boundary flags

  1. Base merge bde765bf (main 67add130 into 7cc0ca1b) — resolution verified on the committed tree, not from the body. On packages/spec/src/migrations/registry.ts the diff from the main parent to the merge is exactly this PR's own hunks: feat(spec)!: split the translation bundle type — settings is a platform group, not a per-app one (#15178) #19600's step18.rationale paragraph stands verbatim, re-terminated with one trailing space, this PR's paragraph appended after it; one conversionIds element; one RETIRED_KEYS_BY_MAJOR[18] row inside the generated region. No rebase, no force-push (7 single-parent commits plus the merge, all present).
  2. Regeneration 9d5fb0ba touches only content/docs/references/data/object.mdx and system/migration.mdx, and every hunk restates a describe that feat(spec): the protocol declares what an ABSENT scale means per field type — percent means 0 #19624 / fix(spec): enableOnInstall becomes optional() so absence survives the parse #19690 / fix(spec): describe format by the readers that exist, not by email/phone #19763 landed on main — regenerator output, not this PR's prose.
  3. Unresolvable citations in added lines: #8778 × 6, #8707 × 1 — all inside *.test.ts (DEFERRED_SURFACES of check-issue-citations.mjs); Lint & Repo Gates green. Same finding as the prior record; the body's tree-wide wording remains imprecise, not a defect.
  4. Residue named by the dev (out_of_scope_findings) — six test fixtures outside the surface still author the key and stay green because they feed drivers and engine doubles, not TenancyConfigSchema; plus the prior record's service-storage/src/backfill-sys-file-organizations.ts prose. Follow-ups, ⛔ not blocks. open_questions: [], premise_still_valid: true.
  5. The needs:contract-review label on this PR and on Retire tenancy.organizationField from the authorable surface — one platform table's fact, not customer configuration #19054 is the label ruling B step ③ retired (PR pm: retire the needs:contract-review label from the tree (ruling B step ③) #19737); inert on the queue guard's current head (Governed Surface Queue Guard success here) — the label object is the skills seat's to delete once carriers clear, ⛔ not this PR's to act on.
  6. CI on 3fb1a499, read from the head's check runs at 2026-09-23T07:07Z: 31 success · 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — duplicate / opt-in jobs) · 0 failed · 0 in progress. All six Test Core shards, all four Type Check lanes, Build Core, Build Docs, Spec property liveness, Temporal Conformance, the three Dogfood Regression Gate shards and Dogfood Verify CLI green; mergeable_state: clean. check-governed-merges.mjs --pr 19618: 0 of 22 paths on the register — NOT governed, ordinary queue landing.

Implemented-by: claude/issue-19054-retire-tenancy-organization-field
Reviewed-by: session_0129ZpnaBcYZZ51rCvQiXg6C

VERDICT: PASS — the contract judgments (accept set narrowed as ruled, public surface unwidened, semver and D2 consistent). What remains before ready is not a contract question: the maintainer's one-word answer to 5780582543 on the inverted control, then the holding seat flips ready and enqueues.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor

维护者裁定:倒置对照「同意」—— 2026-09-23T07:21Z

domain:spec 席位 4(持有本 PR)。

出处三件

  • 谁的指令:维护者
  • 原话:「19618 同意」
  • 在哪说:本席位会话 session_01AmH9bKvGoLjiY86Q4Z3og2 的对话,在本条发出之前(确切分钟未记录,⛔ 不估)

裁定的对象

5780582543(更正见 5787068418)所问的一项:packages/plugins/plugin-audit/src/audit-writers.test.ts 中对照用例改写为「the stamp follows the OBJECT」,断言由 org-actor 倒置为 org-key;org-actor 读数在不带该列的 bare 形上原位重钉。维护者答复 同意 ⇒ 卡 #19054 的 AC5 按本 PR 的写法验收。

落地前提,逐条对账(head 3fb1a4994cb5cfe66f4d67f042213940a9158f4c)

  1. 契约复审 PASS:总监席记录 5790585950,针对本 head。
  2. 倒置对照:维护者 同意(本条)。
  3. CI:本 head 38 success / 4 skipped / 0 failed,mergeable: clean(席位读数,本条发出前)。

⇒ 席位随后经中继执行 pr_ready 与 automerge_enable,并在时间线上确认 added_to_merge_queue。

domain:spec#4 · session_01AmH9bKvGoLjiY86Q4Z3og2

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 23, 2026 07:22
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit 502f179 Sep 23, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19054-retire-tenancy-organization-field branch September 23, 2026 07:43
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… (ruling item 2 + census) (objectstack-ai#19720)

Part of objectstack-ai#17418 — this is the **spec/scripts half** of the ruled change.
It deliberately does not close the card; ruling item 1 (`packages/cli`)
is a sibling PR by the `domain:cli` seat and is listed below with its
real file surface.

Clause-②: no

Ruling: comment 5644350230 (director seat, decision batch objectstack-ai#122 item 1,
maintainer 「同意」 2026-09-12).

## The lane split, and what is NOT here

| ruling item | this PR |
|:--|:--|
| 1. `TEMPLATES` in `packages/cli/src/commands/init.ts` emit the factory
shape; `content/docs/deployment/cli.mdx:1323` describes it | **no** —
`domain:cli` sibling; surface measured below |
| 2. a `*.object.ts` not using the factory is refused **by name** — "use
`ObjectSchema.create`" | **yes** |
| 3. census in-repo literal-shaped object files and convert them |
**yes** — census result: zero to convert |

No file under `packages/cli` is touched.
`content/docs/deployment/cli.mdx` is not touched.
`packages/platform-objects/src/identity/sys-api-key.object.ts` (held by
objectstack-ai#19618) is not touched — the census did not name it, so no serialization
was needed.

## Landing order — measured, not assumed

The hazard to rule out: if the gate refuses the annotated-literal shape
while `os init` still emits it, a freshly scaffolded project would be
born refused.

**It does not arise. Landing item 2 alone turns nothing red that only
item 1 can fix.** Four readings, all on `origin/main` at `4fba5036f2`:

1. **The gate's population is a filename suffix, not a shape.**
`walkObjectFiles` collects files whose name ends `.object.ts`, repo-wide
minus `SKIP_DIRS`. Measured: **112 files, zero of them under
`packages/cli`** (`git ls-files | grep '\.object\.ts$'` also returns
112, so nothing untracked is hiding either).
2. **`TEMPLATES` are string literals inside
`packages/cli/src/commands/init.ts`** — a `.ts` file, not a
`*.object.ts` file. The walk matches by filename, so the template
strings are never read by this gate, in any shape.
3. **Every CLI scaffold test writes into `os.tmpdir()`**, never into the
repo tree (`mkdtempSync(join(tmpdir(), …))` throughout
`packages/cli/test/`). No test run can transiently materialise a
literal-shaped `*.object.ts` inside the walk, and the tree carries no
ignored one either.
4. **`os init` emits into a user's project**, which does not carry this
repo's `scripts/`.

So the seat's reading holds, and it is now a measurement rather than a
reading. Items 2 and 3 land independently; item 1 follows on its own
card.

## Item 2 — the rule, and the hole it actually closes

The gate found object declarations by `CREATE_CALL`
(`ObjectSchema.create(`) and nothing else. One backstop existed: a file
yielding zero declarations **and** zero refusals is refused. That
backstop is conditioned on `objects.length === 0`, so a file holding a
factory declaration **and** a literal one read as complete, and the
literal one was judged by nothing at all.

Measured on a two-file control tree, with the pre-change gate:

```
=== BASE GATE over the control tree ===
files walked      : 2
objects PARSED    : 1 -> ctrl_factory
refusals          : 1
   packages/ctrl/src/objects/pure_literal.object.ts:1  no object declaration recognised in a `.object.ts` file.
```

`mixed.object.ts` produced nothing — and the literal declaration inside
it (`ctrl_hidden`) keys a **UNIQUE index on an unbounded `text`
column**, which is the exact defect this gate exists to catch. That is
the "never silently unprotected again" the ruling names.

The authoring-shape scan now runs **independently of** the factory
parse, over every `*.object.ts`. Its refusal, verbatim from the real
gate binary:

```
check:keyed-text-bounds: 2 object declaration(s) not written with `ObjectSchema.create`

  packages/platform-objects/src/__shape_demo__/mixed.object.ts:10
    `ctrlHidden` is declared as a plain object literal — use `ObjectSchema.create({ ... })`.
    Recognised as a declaration by a `ServiceObject` type annotation, and a literal `name:` beside a `fields:`, the two keys the factory parser requires.
    `ObjectSchema.create` is the one authorised shape for a `.object.ts` declaration: it parses
    the declaration against ObjectSchema when the file is evaluated, so an error surfaces where
    it was written. A typed literal defers every check to a build the author may never run — and
    this gate cannot read it at all, so every keyed text column in it goes unjudged.
    The conversion is mechanical: wrap the literal in `ObjectSchema.create( ... )`.
    ⛔ Do not teach this scan the literal shape instead — the shape is refused, not unknown.
```

Four signals, published in the file header because a source scan sees
only the spellings it knows: a `ServiceObject` annotation (any
indentation), a `satisfies ServiceObject` (any indentation), the file's
default export (top level), and a literal `name:` beside a `fields:`
(top level only — held to column 0 so a helper literal built inside a
function and handed to the factory is not accused).

The zero-declarations backstop now steps aside when the shape scan
already named the reason, so a literal-shaped file gets **one** finding
prescribing the factory rather than a second one inviting the parser to
be widened.

## Item 3 — the census: instrument, control, reach

The card's "112 parsed, all factory" and the seat's "112 files on main"
are **not the same fact**, and a literal-shaped file the gate cannot
parse is invisible to exactly the instrument the card used. So the
census was taken with a separate instrument.

**Instrument** — over-inclusive and shape-agnostic: for every
`*.object.ts` in the same walk, list every top-level binding of an
object literal or of a call, plus every `export default`, then subtract
the ones whose initializer is `ObjectSchema.create(`. Whatever is left
is a candidate for hand triage. It does not depend on knowing the
spelling `Data.ServiceObject`.

**Result:**

```
*.object.ts files walked: 112
top-level bindings/defaults seen: 120
  initialized by ObjectSchema.create(: 117
  initialized by a bare OBJECT LITERAL: 0   <= CANDIDATES for item 3
  initialized by something else: 3
CANDIDATES: none.
```

The 117 matches the gate's own parsed count exactly, from a different
reader. The 3 non-literal bindings were printed rather than counted, and
are plainly not declarations: a regex literal, a template string and
`'sys_http_delivery' as const`.

**Control (lit):** the same instrument over a two-file control tree
returns 2 candidates — the pure literal file and the one hidden behind a
factory declaration in a mixed file — so a zero from it is a reading,
not an empty sweep.

**Reach, stated:** the population is `**/*.object.ts`, which is the
ruled population — the `object` row of `DEFAULT_METADATA_TYPE_REGISTRY`
(`packages/spec/src/kernel/metadata-plugin.zod.ts:725`) declares
`filePatterns: ['**/*.object.ts', '**/*.object.yml',
'**/*.object.json']`. Blind to: declarations in files not carrying that
suffix, the `.yml`/`.json` patterns (not TypeScript, the factory does
not apply), and declarations assembled at runtime rather than written as
a literal. Cross-checks run against the whole tree and reported
separately: zero `export default` and zero `satisfies` occurrences in
any of the 112 files outside comments and strings; zero indented
object-literal bindings.

**So: zero to convert — and that is a different answer from "the gate
saw none".**

## The rule can fail — ablation

The detector was neutered on the committed tree and the mixed control
re-run. `scripts/ablation-replace.mjs` carried the mutation, so the
anchor hit and the blob move are its own verdict rather than a
remembered claim:

```
ablation-replace: anchor   "literalShapeDeclarations(struct, masked).map"  x1 (before)
ablation-replace: ok mutation landed: anchor 1 -> 0, blob bce8e23 -> 71718fb1874b

  with the detector present:  GATE EXIT=2   the gate NAMED ctrlHidden
  with the detector ablated:  GATE EXIT=0   ctrlHidden UNNAMED, silently unprotected

ablation-replace: ok restored: blob == HEAD (bce8e23) and `git diff HEAD` is empty
```

The ablation also found a defect in the first draft of this change: the
ablated run still printed `Authoring shape: 0 literal-shaped
declarations ... every declaration is ObjectSchema.create`, over a tree
holding an unbounded keyed text column judged by nothing. That zero has
no floor under it, so a dead detector printed the identical line. The
pass line now reports what was **scanned** and names `--self-test` as
the liveness proof (commit 2).

A new `--self-test` battery, `the authoring shape: the factory is the
one authorised declaration`, registers 15 cases and is pinned at 15 —
its true registered count, measured at this head — and
`SELF_TEST_BATTERY_FLOOR` moves 9 to 10. Pinning it below its count
would have reproduced this PR's own defect class one level up, in the
ratchet: at a pin of 12 any three cases could be deleted with the floor
still green, including both MIXED cases, which are the only ones that
exercise the hole the rule closes. Every other battery in the roster
pins at exactly its registered count (3/15/8/11/10/5/7/4/4), measured in
one pass by over-pinning each entry to a sentinel and reading the
floor's own `registered N case(s)` line — a RUNTIME count, because one
battery registers through a loop and a literal source count is not a
general method. The regression case is `MIXED`: a whole-file fixture
alone would pass identically with the detector deleted, because the
whole-file case was already refused by the old backstop.

## Changeset — measured, and it is owed by the other half

**This PR publishes nothing.** `scripts/check-keyed-text-bounds.mjs`
sits inside no workspace package directory (checked against every
tracked `*/package.json` directory), and npm packs relative to the
package directory, so it cannot be packed. The repo-root manifest is
`private: true`. No non-private package lists a `scripts` directory in
`files[]`. Positive control: `packages/spec` is non-private and ships
`["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]`
— a real `files[]` exists and does not reach repo-root `scripts/`. The
new symbol `literalShapeDeclarations` occurs in exactly one file, that
one.

`skip-changeset` was applied **by the seat**, with its own measurement
recorded at comment 5775287713 (route 1: the one changed file is
repo-root `scripts/`, inside no package directory; root manifest
`private: true`; 77 manifests censused for `files[]` escape hatches,
zero found, with a lit control). The stale `Check Changeset` red was
re-run in that same act — ⛔ not a flake re-run: the gate's input changed
after it ran.

Worth the seat's attention: the ruling asks the changeset to state the
one mechanical user rewrite (wrap the literal). The rewrite is something
a **user** experiences, and the change a user experiences is item 1 —
`os init` / `os g` emitting the factory shape from a published package.
So the ruling's changeset obligation attaches to the half that
publishes, i.e. the `domain:cli` sibling PR, not to this one.

## What item 1 actually requires — real files

The seat files the `domain:cli` card from this list. Measured, not
guessed:

**Emitters (behaviour change):**
- `packages/cli/src/commands/init.ts:649` and `:744` — the two
`TEMPLATES` entries for `src/objects/__name___item.object.ts`, both
emitting `const ${toCamelCase(namespace)}Item: Data.ServiceObject = {`.
- `packages/cli/src/commands/generate.ts:99` — `os generate object`
emits `const ${toCamelCase(name)}: Data.ServiceObject = {`. **The
ruling's item 1 does not name this file, and it must.** Its own docblock
declares the coupling: "objectstack-ai#9666 took it once for the `os init` templates,
and this emits the SAME value with the same explanation, so the two
doors an author can arrive through agree. If that template's value ever
moves, this one moves with it."

**Pins that move with them:**
- `packages/cli/test/generate-refuses-unparseable-name.test.ts:255` —
`expect(scaffold).toContain('const orderLine: Data.ServiceObject = {')`.
- `packages/cli/test/generate-emission-parses.test.ts:148` —
`expect(scaffold.source).toContain('const foo.bar: Data.ServiceObject =
{')`, plus its docblock at `:14`.
- `packages/cli/test/scaffold-emission-typechecks.test.ts:26` — docblock
states "The repair is `Data.ServiceObject`".
- `packages/cli/test/init.test.ts:493` — reads the scaffolded
`my_app_item.object.ts`; re-check its assertions against the new bytes.
- `packages/cli/test/init-template-comments-self-contained.test.ts` —
the templates carry a long authored OWD comment block that must survive
the rewrite.

**Docblock only, no behaviour change:**
- `packages/cli/src/utils/emitted-source-parses.ts:14` — the utility
itself is shape-agnostic (it asks TypeScript's own parser whether the
emitted bytes parse); only its worked example names the literal shape.

**Coupled, probably no edit:**
`scripts/sync-scaffold-emission-policy.mjs` keeps `create-objectstack`'s
bundled template's `pnpm`/`typescript` ranges in lockstep with
`packages/cli/src/commands/init.ts` (`POLICY_SOURCE`). It syncs version
ranges, not declaration shape — but the sibling should re-run it,
because `create-objectstack`'s bundled `note.object.ts` is already the
factory shape and the two scaffolders would finally agree.

**Docs:** `content/docs/deployment/cli.mdx:1323`.

## Acceptance notes

Two further emitters of the outlawed shape exist **outside
`packages/cli`**, which the ruling names nowhere and which the lane
split therefore routes to neither seat. Both are reported rather than
changed: neither is a `*.object.ts` file, so item 3's population does
not include them, and both sit in published packages, so converting
either would change a published payload and re-open the `Clause-②: no`
reading this PR carries.

-
`packages/services/service-datasource/src/external-datasource-service.ts:931`
emits `const ${definition.name}: ServiceObject = {` as the object draft
that `os datasource introspect --out objects/x.object.ts` writes into a
user's project (ADR-0015). A third scaffolder door, server-side. Pinned
at
`packages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts:138`.
- `packages/metadata/src/serializers/typescript-serializer.ts:23` emits
`export const metadata: ServiceObject = ${jsonStr};` for the
`typescript` metadata format.

Noted, not filed: `provenanceLine`'s record still reads `-1/-1/-5/-2/-4`
against `MEASURED.ref` `fa5d137ab0`, which is information and not a
verdict per that file's own header — no action, and no PR or person is
due to touch it. Carrier: none.

## Verification

- `node scripts/check-keyed-text-bounds.mjs` :: exit 0 — counts
unchanged from base, `112/117/250/592/147`, identical before and after.
⚠️ This bare invocation is **NOT** a liveness reading: with the detector
ablated it still exits 0 and still prints its pass line. What commit 2
closed is the LIE in that line — it no longer claims every declaration
is the factory, only what was scanned — and the CI false green, because
`--self-test` and the gate are wired as one pair (`package.json:183`,
`.github/workflows/lint.yml:731-732`, inside the required `Lint & Repo
Gates` job, all three verified at this head). The paired self-test is
the liveness proof; the bare run is not.
- `node scripts/check-keyed-text-bounds.mjs --self-test` :: exit 0 — 0
failures, all 15 cases in the battery listed by name in the log.
- All 31 gate families derived by `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` were run at `beef275c08`
and again in full at `4d034cd2f9`, `--ran` reconciling both as **31
derived, 31 run, 0 NOT-MEASURED, 0 UNRUN**, every row carrying its exit
code, all 0. ⚠️ Those two are readings at EARLIER heads. The round that
produced `7adf1e0a6c` reports re-running all 31 there with the same
reconciliation, and reports that its first sweep read **5 at exit 3 —
PREREQUISITE NOT MET, a missing `node_modules` in a fresh worktree —
which were NOT MEASURED and never green until `pnpm install` and a
re-run**. That last reading is the delivering round's, ⛔ not re-derived
by the seat; CI at this head is the seat's own reading, below.
- Lint, as a declared narrowing rather than a repo-wide scan: `eslint
--no-inline-config --format json scripts/check-keyed-text-bounds.mjs` ::
exit 0, **1 file** linted (count read from the JSON output), 0 errors, 0
warnings. The narrowing excludes nothing: this repo runs one
`eslint.config.mjs`, which per its own comment at line 327 "never
enables type-aware linting (no `parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file", measured there with a
positive control — so a one-file diff cannot move the verdict on any
file it does not touch. The repo-wide `pnpm lint` was NOT MEASURED in
that round — `scripts/pm/os-verify-lock.sh` returned `queue-timeout
(exit 99)` after 9m00s, never acquiring, holder pid 19251 — neither
green nor red. ⭐ That NOT MEASURED is now superseded by the delivering
round at `7adf1e0a6c`, which reports `eslint . --no-inline-config
--format json` :: exit 0 over **7,005 files, 0 errors, 0 warnings**. ⚠️
Attributed to that round; ⛔ not re-derived by the seat.
- **CI at the current head `7adf1e0a6c`, read by the SEAT by job
conclusions, latest run per check NAME — ⛔ never an aggregate roll-up:
31 names, 23 `success`, 8 `skipped`, 0 `failure`, 0 `in_progress`.** All
five required checks `success`: `Lint & Repo Gates`, `TypeScript Type
Check`, `Test Core`, `Dogfood Regression Gate`, `Governed Surface Queue
Guard`. The 8 skips are the paths filter on a diff that touches no
package.
- The `insideFunction` case now pins the guard it is named for. Ablating
the column-0 guard (`m[1].length === 0, false` → `true, false`): at
`4d034cd2f9` the self-test was exit 0 / PASS / 0 failures; at
`7adf1e0a6c` it is exit 1 / FAIL / 1 failure, the failing case being
exactly that one. Both legs restored, proven by an empty `git status
--porcelain` and an empty `git diff HEAD` rather than by a step's exit
code.

No label, assignee, ready-flip, auto-merge or enqueue was performed, per
the dispatch.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ectstack-ai#19736)

Fixes objectstack-ai#19311

Clause-②: no

Why that reading: two `.describe()` strings and one TSDoc block. No key
is added, removed or re-typed, no closed set gains a member, no export
moves and no refinement changes. `check:authorable-surface` and
`check:api-surface` are both green with no delta, and the wizard-step
and `group` co-declaration refusals parse identically before and after.
Grade: `patch`, measured below rather than pattern-matched.

## The defect, re-derived on `origin/main` rather than taken on trust

`FormSectionSchema` declares two authorable booleans with **no
`.describe()` at all**, between neighbours that have one:

```
packages/spec/src/ui/view.zod.ts:3315   collapsible: z.boolean().default(false),
packages/spec/src/ui/view.zod.ts:3316   collapsed:   z.boolean().default(false),
```

**Lit control, same file, same instrument:** `view.zod.ts:1054` reads
`collapsed: z.boolean().default(false).describe('Collapse groups by
default (presentation only)')` — a different schema (group-by
presentation). So a describe on a key of this name IS visible to the
grep, and the zero at `:3315–3316` is a reading, not a blind spot.
**Dark control:** `git grep -n "collapsedd"` over the same file → 0
hits, exit 1.

Downstream of that silence, the generated page printed two empty
Description cells (`content/docs/references/ui/view.mdx:334-335`, and
again at `:466-467` and `:482-483`, the three places that page projects
this schema).

**This is NOT the surface the first increment landed on.** PR objectstack-ai#19699
(merged `fa29803417`) corrected
`packages/spec/src/data/object.zod.ts:1220` — the `ObjectFieldGroup`
pair, a different schema in a different file, which is why it merged as
`Part of`. Both keys of **this** pair are described here, not just
`collapsed`: describing one of a silent pair recreates the ambiguity one
key over.

## Probe — every sentence measured against the BUILT package

Built with `pnpm --filter @objectstack/spec build`, then probed through
the module the `exports` map resolves
(`require.resolve('@objectstack/spec/ui')` →
`packages/spec/dist/ui/index.js`, printed in the same run).

### A. `FormSectionSchema.safeParse`, one section, all four combinations

| authored | `collapsible` out | `collapsed` out |
| :--- | :--- | :--- |
| neither — **CONTROL (lit)**: default arm must fire | `false` | `false`
|
| `collapsible: true` | `true` | `false` |
| `collapsible: false` | `false` | `false` |
| `collapsed: true` | `false` | `true` |
| `collapsed: false` | `false` | `false` |
| both `true` | `true` | `true` |
| `collapsed: true` + `collapsible: false` | `false` | `true` |
| `collapsed: false` + `collapsible: true` | `true` | `false` |
| both `false` | `false` | `false` |
| `collapsable: true` — **CONTROL (dark)** | REFUSED `unrecognized_keys`
| — |
| `collapsed: 'true'` — **CONTROL (dark)** | REFUSED
`invalid_type@collapsed` | — |

Both lit and both dark controls answered as predicted, so the nine
readings are measurements.

### B. The normalizer question the dispatch asked — answered NO

There is no normalizer, no parse wrapper and no fold between these two
keys. `{ collapsed: true }` parses to `{ collapsible: false, collapsed:
true }`, verbatim, with both keys present in the output
(`.default(false)`), and `safeParseAsync` returns the same. The
section's only `.transform` is `normalizeVisibleWhen`, which touches
`visibleOn` and nothing else.

⇒ That is the **precision limit**, and it is stated in the text rather
than left implied: the implication `collapsed` ⇒ `collapsible` is a
**renderer** rule applied from the declaration, never a parse-time
rewrite. The describes say so explicitly, and say that the parsed
`collapsible` must never be read as "a disclosure control renders". This
is the exact opposite of the `ObjectFieldGroup` pair, where a real
parse-time mapping folds the booleans onto the ADR-0085 `collapse` enum
— the two surfaces share key names and share nothing else, so the
describes name that difference.

### C. The two refusals the describes claim

| input | wizard form | beside `group` |
| :--- | :--- | :--- |
| `collapsed: true` | REFUSED at `sections.0.collapsed` | REFUSED at
`collapsed` |
| `collapsible: true` | REFUSED at `sections.0.collapsible` | REFUSED at
`collapsible` |
| `collapsed: false` | ACCEPTED | ACCEPTED |
| `collapsible: false` | ACCEPTED | ACCEPTED |
| neither — **CONTROL (lit)** | ACCEPTED | ACCEPTED |
| `simple` form + `collapsed: true` — **CONTROL (lit)** | ACCEPTED (the
refusal is wizard-only) | — |
| `group` + `fields` — **CONTROL (dark)** | — | REFUSED at `group` |

⚠️ The first run of this block had its two lit controls come out
REFUSED. That was a malformed fixture on my side (`FormViewSchema` is
the form CONFIG and carries no `name` / `label` / `objectName`), not a
reading — so those rows were discarded and the block re-run after the
fixture was repaired. The table above is the repaired run. Recorded
because the controls are the only reason the first, wrong version did
not ship as a finding.

### D. Reverse verification — the gate can see this change

After the describe edit and a spec rebuild, `pnpm --filter
@objectstack/spec check:generated` exits **1** and names exactly one
stale artifact — `content/docs/references/**` — while the other 14 stay
green. **Direction observed = turns red, as predicted.** After
`gen:schema && gen:docs` the same aggregate exits 0. That is what makes
the final green a measurement rather than a silence.

## Downstream consumer, read at the PINNED sha

Read at `.objectui-sha` = **`87af769e9a3ee28ace099fdd653d3ebd79fe82e2`**
(not objectui's head, which is `0cf2d66`); `git merge-base
--is-ancestor` on the pin exits 0, which is self-proving.

- `packages/plugin-form/src/ObjectForm.tsx:1560` applies the ruling:
`Boolean(section.collapsible) || Boolean(section.collapsed)`, with a
block comment that states it is read from the DECLARATION and never from
live collapse state, and that letters B (refuse the combination) and C
(a dev-only warning) were both refused. The describes follow that, and ⛔
neither adds a refinement nor asks for a lint rule.
- `packages/plugin-form/src/DrawerForm.tsx:609` and `:677` still push
`collapsible: section.collapsible` **alone** on both drawer arms. That
file's own comment declares the gap deliberate and fenced, and says
converging it *is* the `collapsed` / `collapsible` decision. **So the
contract this PR publishes is the thing that arm was waiting on** — it
is a sibling-repo residual, another seat's card, and nothing here
touches it. Reported, not ridden along.
- In **this** repo nothing consumes the raw pair: `git grep` for a
`.collapsible` / `.collapsed` member access across `packages/**` returns
only the `ObjectFieldGroup` mapping and test files; `packages/lint/src`
has **0** hits for `collapsible`, with a positive control
(`form-section-group-unknown`, 2 files) firing on the same command and
scope.
- All 22 first-party form producers under
`packages/spec/src/**/*.form.ts` pair `collapsible: true` with
`collapsed: true`, so **zero** in-repo producers exercise the trap. It
is an author-facing trap, not a live in-repo defect — which is precisely
why the remedy is declaration text.

## Generated artifacts — the four-step sequence, run mechanically

`content/docs/references/**` is routed `merge=os-regen`, the class that
merges with exit 0 and zero conflict markers while silently dropping one
side.

1. `bash scripts/pm/os-regen-merge.sh` — steps 1–3, exit 0. It merged
`origin/main` (`e37ea4d060`), took main's side of the generated
artifacts main moved, and committed the merge **before** any
regeneration. ⛔ No rebase, ⛔ no force-push, ⛔ no `git stash`, ⛔ no hand
merge of a routed path.
2. `gen:schema` was run only **after** `MERGE_HEAD` was gone and the
tree was clean, so the authorable-surface anchor could not roll back to
the old fork point. `packages/spec/authorable-surface.base.json` is
byte-identical to `origin/main`.
3. Regenerated `pnpm --filter @objectstack/spec build && gen:schema &&
gen:docs`. **Exactly one** file moved, and by exactly the rows this one
change derives: 6 rows in `content/docs/references/ui/view.mdx` — the
pair, three times, because that page projects `FormSectionSchema` three
times. `git diff --name-only origin/main -- content/docs/references/`
names that file and no other.
4. Inspected the **staged** diff (`git diff --cached`), not the
working-tree diff, before committing.

### Survival assertion for the in-flight sibling, PR objectstack-ai#19618

objectstack-ai#19618 (open, draft, head `7cc0ca1b3d`) touches four
`content/docs/references/**` pages. It is **unmerged**, so what my
regeneration could destroy is main's copy of those pages. Quoted-exact
`git grep -F`, counts identical on my tree and `origin/main`:

| needle | path | mine | `origin/main` |
| :--- | :--- | :--- | :--- |
| the pre-objectstack-ai#19618 `tenancy` type spelling carrying `organizationField?` |
`api/metadata.mdx` | 1 | 1 |
| same | `data/object.mdx` | 1 | 1 |
| same | `system/migration.mdx` | 2 | 2 |
| objectstack-ai#19618's post-change `tenancy` spelling | all four | 0 | 0 (it has not
landed) |
| `STAMP-ONLY: column carrying the organization a row is ABOUT` (its
deletion target) | `data/object.mdx` | 2 | 2 |
| **CONTROL (lit)** `Multi-tenancy configuration for SaaS applications`
| all four | 1 / 1 / 2 | fires |
| **CONTROL (dark)** a near-miss of the same spelling | all four | 0,
exit 1 | discriminates |

All four of its reference pages are **byte-identical** to `origin/main`
in my tree (`git hash-object` vs `git rev-parse origin/main:PATH`, four
matching pairs). Every os-regen-routed path outside
`content/docs/references/ui/view.mdx` differs from `origin/main` by
**zero** files.

Also asserted, because the merge driver swallows implementation bodies
and not only index entries: the **previous increment's** body survives —
`git grep -cF "true → 'collapsed' (collapsible, starts closed) on its
own"` reads 1 in `packages/spec/src/data/object.zod.ts` and 2 in
`content/docs/references/data/object.mdx`, identical on my tree and
`origin/main`, with a near-miss dark control at 0 / exit 1.

## Changeset — measured, not pattern-matched

`npm pack --dry-run --json` in `packages/spec` (exit 0, 2031 files):

| file | verdict |
| :--- | :--- |
| `src/ui/view.zod.ts` — **the edited file** | SHIPS |
| `package.json` — **positive control** | SHIPS |
| `src/data/object.zod.ts` — **positive control** | SHIPS |
| `src/ui/view.test.ts` — **negative control** | does NOT ship |
| `scripts/build-docs.ts` — **negative control** | does NOT ship |
| `tsconfig.json` — **negative control** | does NOT ship |

Scanning the tarball's own file list for the new describe text: **42
shipped files carry it** — 30 under `dist/`, 11 under `json-schema/`, 1
under `src/`. Negative control with a lit leg: the string `are GENERATED
— do not hand-edit them` is present in
`packages/spec/scripts/lib/generated-output.ts` and in **0** shipped
files, because that file does not ship.

⇒ The bytes publish, so `skip-changeset` would be a false declaration. A
`patch` changeset is in the diff.

## Acceptance notes — found, ⛔ not fixed here

- `packages/spec/src/ui/view.zod.ts:3274` and `:5951` describe the pair
in prose and in a doc example without the dependency; neither is
falsified by this change and neither is a trap as written (the `:5951`
example writes both keys). Noted, not filed.
- The `sections` / `groups` rows on the generated page print a truncated
inline type (`… collapsible?: boolean; …`) with no Description at all.
That is the generator's type-column truncation, not this pair, and it is
unchanged here.
- `packages/lint` carries no rule on this pair (0 hits, positive control
firing). Consistent with the 2026-09-18 ruling, which refused letter C —
a dev-only warning — so this is a deliberate absence, ⛔ not a gap to
fill.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…phone (objectstack-ai#19763)

Fixes objectstack-ai#19679

Clause-②: no

One published sentence described `FieldSchema.format` as `Format string
(e.g. email, phone)`: two example words, with no field type and no
reader named. It shipped verbatim into `packages/spec/json-schema/`,
into `dist`, into the published `src/**/*.zod.ts`, and to a customer in
`content/docs/references/data/field.mdx`. On an `autonumber` field the
same key is the record-number pattern, so following the documented
example there minted `email1`.

This round fixes the sentence and nothing else. The key is still
`z.string().optional()`: nothing is split, narrowed, retired or gated by
type, and no consumer is touched.

## What reads the key, measured and stated positively

Round 2 rewrote this section. Round 1 listed two readers and a `nothing
else` row. The at-tier review found a third reader at the pinned
objectui, so this table lists what each measured reader does and makes
no claim that the list is complete.

| reader | field types | what the value means there |
|:--|:--|:--|
| `resolveAutonumberFormat`
(`packages/spec/src/data/autonumber-format.ts:196`), minted through by
`packages/objectql/src/engine.ts` `applyAutonumbers` (`:5051`) and twice
by `packages/drivers/driver-sql/src/sql-driver.ts` (`:10639`, `:10744`)
| `autonumber` | the record-number **pattern**: canonical
`autonumberFormat` first, then this key, then the default `{0000}` |
| `lintAutonumberFormats`
(`packages/lint/src/lint-autonumber-formats.ts:60-62`), wired into `os
lint`, `os compile` and `os validate` through
`packages/lint/src/authoring-rules.ts` | `autonumber` | the same
pattern, linted at build time for unrecognised `{...}` tokens and bad
`{field}` references |
| objectui `DateCellRenderer` and `DateTimeCellRenderer`
(`packages/fields/src/index.tsx:1127`, `:1176`) at the pinned
`.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` | `date`,
`datetime` | a display **style**, with its own words and a different
default per type |
| objectui `resolveCellRendererType`
(`packages/fields/src/index.tsx:2915-2947`), same pin; called from the
grid (`plugin-grid/src/cellRendererResolution.ts:114`), the detail views
(`DetailSection.tsx:411`, `DetailView.tsx:1298`,
`HeaderHighlight.tsx:137`, `RelatedList.tsx:1282`), and the kanban,
gallery, report and dashboard views | textual base types (`text`,
`textarea` and the rest of `TEXTUAL_BASE_TYPES`) | a cell-renderer
**hint**: a small word set promotes the cell to a richer renderer.
Pinned end to end by
`packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx`,
where `{ type: 'text', format: 'phone' }` renders a `tel:` anchor |
| objectui `renderFieldValue`
(`packages/plugin-dashboard/src/recordFields.tsx:382-427`), same pin,
fed the object field's `format` at `:334` | any | a display **pattern**:
a leading currency symbol, a `%`, or any of the letters `Y`, `M`, `D`,
`H`, `m`, `s` selects a formatter, and anything else falls through to
`resolveCellRendererType` |

Run against this branch's build of `@objectstack/spec`
(`dist/data/index.mjs`), seq 1:

```text
format: 'INV-{0000}'                             -> INV-0001
format: 'email'                                  -> email1
{ autonumberFormat: 'A-{000}', format: 'email' } -> A-001
{}                                               -> 0001
```

So an author who followed the key's own documentation onto an
`autonumber` field got `email1` as a business identifier. It parsed, it
stored, and neither the runtime nor the build-time lint reported it:
`lintAutonumberFormats` returns no finding for `format: 'email'`, while
a `{nope}{000}` control draws `autonumber-references-unknown-field`.

**The negatives the description keeps, each with its radius:**

- *On any non-`autonumber` field the server does not act on the key.*
Radius: this repo's `packages/**` non-test sources at `a8c7f17751`.
Instrument: `git grep` over every non-call property read of `format`
(107 hits). Triaged, the only field-definition reads are
`autonumber-format.ts:199` and `lint-autonumber-formats.ts:62`, both on
the autonumber arm. A word census over `packages/drivers` and
`packages/objectql/src` finds every field-reading line gated on the
autonumber type (`sql-driver.ts:10638`, `:10743`; `engine.ts:5044`).
Firing control: the same instrument returns the two known readers. Named
blind spots: multi-line destructuring, computed keys, and whole-object
forwarding. Corroboration: `driver-sql`'s own `FIELD_KEY_STORAGE_CLASS`
classifies `format` as `presentation`
(`packages/drivers/driver-sql/src/builtin-column-collision.ts:97`).
- *The write-time record validator never reads it.* Radius:
`packages/objectql/src/validation/record-validator.ts`. `def.format` has
0 reads, against `def.type` 7 and `def.maxLength` 2 as lit controls.
`const t = def.type` is at `:628`, and the email, url and phone checks
at `:746`, `:749` and `:752` test `t`.
- *The spec checks nothing but that it is a string.* 49 `FieldType`
members times 6 values (`email`, `phone`, `url`, `relative`, an
arbitrary string, `INV-{0000}`) gives 294 cells, run through both
`FieldSchema` and `ObjectSchema`: 0 issues on a `format` path. Firing
control: `format: 123` draws 1 issue on each.
- *The two named objectui arms fall back silently.* Read at the pin: the
date cell hands the word to `formatDate`
(`packages/core/src/utils/date-display.ts:198`), which honours `short`
and `relative` and otherwise paints its default face. The datetime cell
handles `relative`, `short` and `compact` and hands any other word to
`formatDateTime`, which paints its default face (`index.tsx:1243-1282`).
`resolveCellRendererType` returns the base type's renderer for a word
outside its map (`:2943-2946`). None of the three warns or throws.

**Where a value check lives**, which the description now states: the
record validator's email, url and phone checks key on the field **type**
(`type: 'email'`), and the closed `email | url | phone | json`
vocabulary belongs to a **`format` validation rule**
(`FormatValidationSchema`,
`packages/spec/src/data/validation.zod.ts:204`, its `format` key at
`:214`). The description no longer says `email`, `url` and `phone` are
"not formats". On a plain-text field objectui reads them as renderer
hints, and whether that reading should become a declared vocabulary is
the decision this card leaves open.

## Write surface

`packages/spec/src/data/field.zod.ts` — the one `describe` string.
Everything else in the diff is the repo's own generators: `pnpm --filter
@objectstack/spec gen:docs` rewrote
`content/docs/references/data/field.mdx`, `data/object.mdx` and
`system/migration.mdx` (three projections of one string). Nothing was
hand-edited under `content/docs/references/`.

**The conditional fence did not trip.**
`packages/spec/authorable-surface/data.json` is held by open PR objectstack-ai#19618.
`check:authorable-surface` ran green across the edit and the file did
not move — a description is not an authorable key — so no hunk under
another claim was touched.

## Changeset — measured, not assumed

`patch` on `@objectstack/spec`. `skip-changeset` would have been wrong,
and the measurement is the reason rather than a rule of thumb:

- `packages/spec/src/data/field.zod.ts` is itself a published file — it
matches `src/**/*.zod.ts` in this package's `files[]`.
- Greping a distinctive fragment of the new text (`record-number
PATTERN`) over each `files[]` entry: **22** `dist/` files and **13**
`json-schema/` files carry it.
- Positive control from the same source — `required`'s existing
published describe (`Write-time contract (ADR-0113)`) — returns **22**
and **13** over the same two trees. Same counts, so the route is
measuring what it claims to measure.

## Is a regression test owed? No, and here is the reasoning rather than
a silence

- **A negative pin is impossible here.** The obvious pin — assert the
description never says `email` or `phone` — goes red on the
*correction*, because the new sentence deliberately names both words in
order to redirect the author to the field `type` and the validation
rule. The defect was a false sentence, not the presence of two words.
- **A positive pin on this prose would rot by design.** The next honest
sharpening of the sentence breaks it, so the next author edits or
deletes the pin — a check nobody trusts is worse than none, and this
lane's rule prefers deleting the construct that permits the error over
adding a check.
- **The construct that permits the error is out of scope this round.**
It is `z.string()` with no declared vocabulary and no type gating —
narrowing it is exactly the contract-shape decision triage reserved.
- **Every behaviour claim the new sentence makes is pinned, but not all
of it in this repository.** The autonumber claims are pinned here by
`packages/spec/src/data/autonumber-format.test.ts`:
canonical-over-shorthand precedence, the `{0000}` default rendering as
`0001`, and the no-slot branch. The two objectui arms are pinned in
objectui at the pin:
`packages/fields/src/__tests__/datetimeCell.formatVocabulary-8853.test.tsx`
for the date and datetime styles, and
`packages/plugin-grid/src/__tests__/formatHintedColumnRenderer-8920.test.tsx`
for the plain-text hint. The description names those arms without
copying their words, so an objectui change to a word list cannot make
the spec sentence false. An objectui change that removes an arm could,
and nothing in this repository would go red. That residual risk is why
the arms are introduced as examples.
- What *would* earn its place is a gate asserting that every example
value a `describe` offers is honoured by some reader. Nothing like it
exists, building it is well outside a one-sentence repair, and it
belongs with the decision below.

## Verification

Final commit `a8c7f17751`. No merge of `origin/main` this round:
`origin/main` is one commit ahead (`ed4b655e5b`, `scripts/pm/**` only)
and touches none of this PR's five files, so `os-regen-merge.sh` was not
needed. The branch still carries round 1's merge of `dc1b98680b`.

| check | result at `a8c7f17751` |
|:--|:--|
| `pnpm --filter @objectstack/spec build` (under
`scripts/pm/os-verify-lock.sh`) | `VERDICT command-exit 0` |
| `pnpm --filter @objectstack/spec typecheck`, then `test`, in one
locked run | `VERDICT command-exit 0`; 516 test files, 15056 tests
passed, 1 todo |
| `gen:schema`, then `gen:docs` | rewrote exactly `data/field.mdx` (1
row), `data/object.mdx` (2 rows) and `system/migration.mdx` (2 rows) |
| `pnpm --filter @objectstack/spec check:docs` | exit 0: `225 generated
files in sync with packages/spec` |
| `pnpm --filter @objectstack/spec check:generated` | exit 0: `All 15
generated artifacts are up to date` |
| sentence census | the new sentence appears in `field.mdx` 1 time,
`object.mdx` 2 times and `migration.mdx` 2 times; the round-1 sentence
and the original sentence appear 0 times in each. `git diff dc1b986
HEAD -- content/docs/references` is the five `format` rows and nothing
else |
| every family `scripts/pm/dispatch-gates.mjs` derives for this path
set, run from its own `--commands` list with each exit recorded |
`--ran`: 101 derived, 98 run at exit 0, 3 NOT MEASURED, 0 unrun |
| eslint, narrowed | 1 file linted (`field.zod.ts`): 0 errors, 0
warnings, read from `--format json`. The `.mdx` pages and the
`.changeset/*.md` answer `File ignored because no matching configuration
was supplied`. `eslint.config.mjs:327-329` records that type-aware
linting is enabled for no file, so this diff cannot move the verdict on
an untouched file |

**NOT MEASURED, declared to CI:** `check:skill-examples`,
`check:dual-build-cjs-loads` and `check:lean-entry-closure` exited 3
(`PREREQUISITE NOT MET`). Each reads built output of packages this diff
does not touch: `client-react`, the whole workspace, and `objectql`. Not
a pass and not a finding.

**The red at `66cb68d947` was an intermediate head.** That push carried
the new `describe` before its regenerated pages. `875c198d2c` added
them, but its run was superseded by the next push before the docs-sync
step ran (`Type Check · source gates` cancelled, step 26 `Check
generated reference docs are in sync with the spec` skipped), so that
head is **not measured**. At the current head `a8c7f17751` the same step
reads `success`.

## Acceptance notes

**1. The key-vocabulary decision card triage said was owed does not
exist yet.** Triage graded this card "scoped to the `describe`" and said
the wider problem "belongs in the decision box as its own card" —
nothing has been filed, and this round deliberately does not file it.
The readings below are recorded so whoever files it starts from
measurements instead of from scratch.

The shape of the decision: **one `z.string()` key is read by at least
four readers with four different vocabularies (the autonumber pattern,
the date and datetime style, the textual renderer hint, and the
dashboard display pattern), with no declared value set and nothing that
checks the readers agree.**

- **Autonumber arm.** `resolveAutonumberFormat`
(`packages/spec/src/data/autonumber-format.ts:196`);
`AutonumberFormatSource` declares `format` as the shorthand predating
`autonumberFormat`; call sites `packages/objectql/src/engine.ts`
`applyAutonumbers` and `packages/drivers/driver-sql/src/sql-driver.ts`
(two). A pattern-less value renders as literal text plus the bare
counter — measured `email1` above — and nothing refuses it.
- **Display-style arm** (objectui, pinned `.objectui-sha`
`87af769e9a3ee28ace099fdd653d3ebd79fe82e2`):
`packages/fields/src/index.tsx:1127` `const style = dateField.format ||
'relative';` for the `date` cell, and `:1176` `const authoredFormat =
(field as DateTimeFieldMetadata | undefined)?.format || 'compact';` for
the `datetime` cell. Two different defaults for one key. The
vocabularies differ too: `formatDate`
(`packages/core/src/utils/date-display.ts`) honours `short` and
`relative` and silently paints its default locale face for anything
else, while the datetime cell maps `relative` and `short` onto its own
faces and falls through for everything else, `compact` and date patterns
such as `YYYY-MM-DD` included.
- **Renderer-hint and display-pattern arms** (objectui, same pin), found
by round 2's at-tier review and census. `resolveCellRendererType`
promotes a textual field by a word set
(`packages/fields/src/index.tsx:2915-2946`), and `plugin-dashboard`'s
`renderFieldValue` reads the same key as a display pattern
(`recordFields.tsx:382-427`). The two disagree on at least one word.
`format: 'email'` on a `text` field is a `mailto:` hint to the resolver,
but the dashboard's pattern test matches its `m` and hands the value to
`formatDate`, which answers an em dash. That defect is reported
separately in the round-2 report.
- **Blast radius today is documentation, not data.** No field anywhere
in this repository authors `format` — measured over
`packages/*/src/objects` and `examples/*/src/data`. ⚠️ The published
`skills/` corpus is different: it teaches the key on the autonumber arm.
`skills/objectstack-data/rules/field-types.md` spells `{ type:
'autonumber', format: 'CASE-{0000}' }` under its Autonumber heading and
uses `format:` in both `order_no` examples. That is the shorthand this
describe now tells a new field to replace with `autonumberFormat`, so
the skill and the describe disagree on which spelling to teach. That
disagreement is input for the key-vocabulary decision below, ⛔ not a
change made here. What the false sentence reached was the reference docs
and the JSON Schema, i.e. exactly what an AI authoring agent reads
before writing a field.
- **The options the card will have to weigh** are all surface-moving and
all reserved: split the key (add a `displayFormat`, or promote the style
arm), retire one arm under ADR-0049 enforce-or-remove, narrow the type
to a declared vocabulary, or declare the two-arm shape and make each arm
refuse the other's words loudly. Each widens or removes a published
surface.
- Dedupe words for whoever files it: `format`, `autonumberFormat`,
`displayFormat`, `field vocabulary`, `date display style`.

**2. `packages/spec/liveness/field.json`'s `format` row understates the
key.** It is a bare `{ "status": "live", "evidence":
"packages/objectql/src/engine.ts" }` — no `verifiedAt`, no function
anchor and no note, and it records only the autonumber reader. Its own
siblings are richer on exactly the two axes it is missing:
`autonumberFormat` names the consuming function, and `rows` carries
`evidenceScope: "cross-repo"` for a key objectui reads. The objectui
display-style arm is invisible in this row. Not touched here — a ledger
row is not the `describe` this card scopes — and it is work the decision
card above has to redo anyway, so it is recorded rather than filed.

**The same false meaning on six hand-written doc rows — filed as objectstack-ai#19764,
⛔ not fixed here.** This PR repairs the key's own `describe`. Six
hand-written rows in `content/docs/data-modeling/field-types.mdx` (`:24`
under `text`, `:71` under `phone`) and
`content/docs/data-modeling/validation-rules.mdx` (`:46` `text`, `:64`
`email`, `:72` `url`, `:80` `phone`) credit `format` with validation it
never performs. The reader, measured at source:
`packages/objectql/src/validation/record-validator.ts:628` binds `const
t = def.type`, and the three shape checks at `:746` / `:749` / `:752`
are `t === 'email'` / `'url'` / `'phone'`; that file reads `def.format`
**0** times against a same-file `def.type` lit control of **7**. ⇒ on
**validation**, this PR's `describe`, which sends an author to `type`
and to the `format` validation rule, is the correct side. On display it
is not the whole story: the `text` row (`field-types.mdx:24`) describes
a live renderer hint at the pinned objectui (see the reader table),
which the seat recorded on objectstack-ai#19764. Three of them also declare a
`Default` of `email` / `url` / `phone` that does not exist:
`FieldSchema.parse({ name: 'x', type: 'email' })` returns no `format`
key at all. Out of this PR's scope — triage scoped the card to the
`describe`, and hand-written docs are outside its claimed file surface —
so they ride their own card, where the routing question (hand-written
`content/docs/**` versus the `FieldSchema` surface it describes) is
triage's.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e, applied nowhere (objectstack-ai#19657)

Fixes objectstack-ai#19580

`Clause-②: yes (narrowing)`

Ruled: comment `5770606746`, batch objectstack-ai#211 item 1, **letter A** — retire
`connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; the
standing 2026-09-10 「以协议为准」 ruling; the 2026-08-27 「no staged
retirement」). Removal route: the `spec-property-retirement` playbook.

## What this removes

A **fully authorable** key — bounded (`min(1000).max(300000)`),
defaulted (`30000`), `.describe()`d, writable on `ConnectorSchema` and,
through `DeclarativeConnectorEntrySchema`, on `stack.connectors[]` and
`PUT /meta/connector/:name`, and served back by `/meta/connector`. Every
signal an authoring surface can give said it worked. Plus the
`ConnectorProviderContext.connectionTimeoutMs` member handed to every
provider factory.

`requestTimeoutMs` is the replacement: the deadline the platform
actually keeps, applied as `resilientFetch`'s per-attempt timeout.

## ⭐ The measurement the ruling left to the dev — D2 or D3

The ruling prescribed a **D3 semantic** entry and said a **D2
conversion** is owed **only if a stored connector row can carry the
key** — 「the dev measures」. It can, so **both** ship.

Measured first-hand on this branch, before the tombstone landed, against
the built `packages/spec/dist`:

| leg | reading |
| --- | --- |
| `getMetadataTypeSchema('connector')` bound? | `true` — it resolves
`DeclarativeConnectorEntrySchema`, the shape `PUT
/api/v1/meta/connector/:name` validates against |
| door parses a body carrying the key? | `true` |
| does its **output retain** the authored value? | **`4321`** — so the
number reaches `sys_metadata` |
| control on the same instrument | the already-retired sibling
`errorMapping` on the same schema is **refused** — the door
discriminates rather than accepting everything |
| `applyConversionsToStoredItem('connector', row)` live for this type? |
**yes** — it fired `connector-error-mapping-removed` and stripped that
key from a stored row |
| control on the same seam | the same row's `requestTimeoutMs` survived
untouched, so the strip is attributable |

**What would have made it the other answer:**
`getMetadataTypeSchema('connector')` returning `undefined` (no write
door ⇒ no stored row), or the door's output dropping the key, or the
rehydration seam never reaching `connector` rows. All three fired the
other way, so a D2 conversion is owed and a D3-only kit would have left
17.x rows carrying a key the schema now refuses.

Both dispositions are re-measured by pins in
`packages/spec/src/integration/connector-connection-timeout-retirement.test.ts`,
so the answer cannot rot into an assumption.

## ⚠️ The card is STALE, not wrong — and this section previously said
otherwise

⛔ This section's earlier premise is known false and is replaced rather
than patched. It claimed the card's Leg-2 table 「was already false when
this retirement was taken」. It was not.

**At the SHA the card cites and dates — `0870fb5418`** — `git grep -n
connectionTimeoutMs SHA -- . ':!packages/spec'` returns **exactly five**
non-spec source hits, and **all five are `connectionTimeoutMs:
30000,`**: the card's table, line for line. ⇒ the card was **correct
when measured**. What moved it is `b929e0a662` (objectstack-ai#19388) — the very PR
the card itself flagged as pending.

**At `origin/main`** the same instrument returns **thirteen** non-test
source occurrences over **seven files** in **five packages**: six reads,
four type declarations, and three surviving pure hardcoded `30000`
writes (`connector-mcp/src/mcp-connector.ts`,
`connector-slack/src/slack-connector.ts`,
`service-automation/src/plugin.ts`). ⚠️ Seven files, not five — five is
the count of *packages*, and conflating the two is how the earlier
number was reached.

| site | what it is |
| --- | --- |
| `services/service-automation/src/plugin.ts:307` | reads
`entry.connectionTimeoutMs` into the **materialization fingerprint** |
| `services/service-automation/src/plugin.ts:1589` | reads it onto
`ConnectorProviderContext` |
| `connectors/connector-rest/src/rest-provider.ts:64` | reads
`ctx.connectionTimeoutMs` |
| `connectors/connector-openapi/src/openapi-provider.ts:193` | reads
`ctx.connectionTimeoutMs` |
| `connector-rest/src/rest-connector.ts:134`,
`connector-openapi/src/openapi-connector.ts:242` | `?? 30000` — read the
opts and deposit the value on the reported def |

**The ruling's premise survives, and the mechanism is unchanged.** Every
read is a **pass-through**. The value's only termini are (a) the def
`GET /connectors` echoes and (b) the fingerprint that decides whether to
re-materialize. `connectorFetchOptions()`
(`integration/connector-fetch-policy.ts`) is handed `{ retryConfig,
requestTimeoutMs }` only, and a pin has asserted since objectstack-ai#18975 that
nothing aliases this key onto `timeoutMs`. **Carrying a number is not
honouring it** — the parsed-unmarked-unenforced state ADR-0049 forbids,
wearing a longer route.

Nor was the `实现` arm available: a WHATWG `fetch` exposes one
`AbortSignal` over the whole operation and never the connect phase, so
bounding time-to-response with this key would kill a slow-but-connected
upstream the author meant to allow with a large `requestTimeoutMs`.

## Zero-enforcement verification, with its control

- **Claim:** nothing applies the value as a deadline. **Instrument:**
`git grep -n connectionTimeoutMs` over the whole worktree (45 hits,
hand-read, not counted) plus the source of `connectorFetchOptions()`.
**Radius:** the monorepo. **Control:** `requestTimeoutMs` — same schema,
same census, same files — resolves to a real read (`opts.timeoutMs =
policy.requestTimeoutMs`), so the instrument is not dead.
- **Pinned sibling checkout:** `git grep connectionTimeoutMs` at
objectui `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` (the `.objectui-sha`
pin) → **exit 1, zero hits**; control `connector` on the same command
and scope returns **458 lines across 66 files**; and `requestTimeoutMs`
is exit 1 / 0 lines there, so it is **not** a usable control in that
repo (it is in objectstack). ⇒ the `Console Pin Gate` needs no sibling
fix and no pin bump with this removal.
⚠️ **That clearance is about the sibling BUILD, and it is not the whole
picture.** The residue stage makes both carriers `z.preprocess` pipes,
and objectui's
`packages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts:468`
asserts `checks(DeclarativeConnectorEntrySchema) > 0` — **1** against
`main`, **0** here, because a pipe def has no `checks` array. The SPA
still builds and `Console Pin Gate` never runs that suite, so no gate in
either repo sees it. objectui resolves `@objectstack/spec` from the
registry at `^17.0.0`, so ⛔ `main` does not go red on merge — the break
lands at objectui's next spec bump. Tracked at **objectui#10211**; the
gate-reach gap at **objectstack#19692**. ⛔ A `.objectui-sha` bump is
never a rider on another PR, so neither rides here.
- **tsc is the real sweeper.** `retiredKey()` types the key `never`, so
every authoring site in the monorepo fails to compile. All six affected
packages typecheck green after the cleanup, which is what says the
census is complete rather than the grep.

## The retirement kit

- `retiredKey()` tombstone on the non-strict `ConnectorSchema` (a bare
delete would be a silent strip, ADR-0104), inherited by
`DeclarativeConnectorEntrySchema`.
- `RETIRED_KEYS_BY_MAJOR[18]` × 2 —
`integration/Connector:connectionTimeoutMs` and
`integration/DeclarativeConnectorEntry:connectionTimeoutMs` — as
one-file-per-entry under `migrations/entries/retired-keys/`.
- **D2** `connector-connection-timeout-ms-removed` in
`conversions/registry.ts`, wired into the step-18 chain.
- ⭐ **ADR-0087 residue stage** `acceptRetiredDefaultResidue` on **both**
carriers with `{ connectionTimeoutMs: 30000 }`. A D2 does **not**
discharge this: the ruled precedent
`18.security__ObjectPermission__allowPurge` carries **both** a D2
(`permission-allow-restore-purge-removed`) **and** the residue stage, so
D2 coverage cannot be the discriminator. The discriminator is whether a
released toolchain MATERIALIZED the default — a 17.x toolchain emits
`connectionTimeoutMs: 30000` into every connector entry, authored or not
— and the second door is `AutomationEngine.registerConnector`, which
parses `ConnectorSchema` for a def a plugin builds **in code**, where no
conversion runs. Without the stage a 17.x connector package fails
registration on a value its author never typed. Head now
accepts-and-strips `30000` while still refusing `15000`, `1000` and
`"30000"`. The preprocess pipe this introduces moves **five** ADR-0097
refinement sites onto its OUT side, so
`dropped-refinements.baseline.json` moves with them — exactly the moves
the build gate printed, no additions.
- **D3 semantic**
`connector-provider-context-connection-timeout-ms-retired` for the
withdrawn `ConnectorProviderContext` member — a provider factory is
code, so there is no authored source for a conversion to rewrite.
- `liveness/connector.json`: the row **stays** `dead` with a `REMOVED`
note, because `retiredKey()` keeps the key in the walked shape (the
`rls.priority` precedent). Its stale 「every occurrence outside
`packages/spec` is a WRITE」 claim is corrected there, with the reads
named.
- Baselines: `authorable-surface/integration.json` gains two `[RETIRED]`
rows, `authorable-defaults/integration.json` loses the two `= 30000`
rows. `api-surface/` and `json-schema.manifest/` are **byte-identical**
— the correct reading for a key-only tombstone that retires no def, not
a missed regeneration.
- Consumers cleaned: the four connector packages and
`service-automation` (fingerprint, declared-item shape, context build,
degraded husk).
- **Declared widening, round 4:** `packages/spec/liveness/README.md`'s
`connector` row asserted, present tense, that the entry schema **is**
`ConnectorSchema.superRefine(...)` — and rested its
byte-identical-key-set conclusion **on** that attachment. Both halves
are corrected: the mechanism is now the pipe's read-through `shape`, and
the conclusion is re-measured rather than inherited (30 keys each
carrier, byte-identical, zero entry-only, zero base-only). ⚠️
Hand-edited on purpose, ⛔ never regenerated: `.gitattributes:71-77`
splits `liveness/state-counts.md` (driver-managed numbers) from
`liveness/README.md` (hand-written Notes prose), because 「regenerating a
Note would fabricate a verdict」.
- ⚠️ **A SECOND, declared narrowing: the `ZodObject` combinators leave
both published exports.** Wrapping `ConnectorSchema` and
`DeclarativeConnectorEntrySchema` in the residue stage makes them
`z.preprocess` **pipes**, so `.extend()`, `.omit()`, `.pick()`,
`.partial()`, `.merge()`, `.strict()`, `.keyof()` and `.safeExtend()` no
longer exist on them. Build on the object and re-wrap —
`acceptRetiredDefaultResidue(<the extended object>, {
connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema`
route. ⚠️ `.superRefine()` still **exists** on a pipe and is callable,
but returns a schema with **no read-through `shape`** — which is exactly
what the schema walkers duck-test — so refine before wrapping, never
after. Parsing, `z.input` / `z.infer` and the read-through `.shape` are
unchanged. The changeset's FROM → TO carries this row; the docblock at
`connector.zod.ts` and the superseded sentence it replaces carry it in
the source.
- Changeset `Clause-②: yes (narrowing)`, `minor` on `@objectstack/spec`
(the launch-window gate refuses `major`), `patch` on the five consumer
packages, with the FROM → TO table and the ADR-0087 disposition marker.

## Tests and gates run locally

| run | verdict |
| --- | --- |
| `pnpm --filter @objectstack/spec build` | exit 0 |
| dependency-closure build of the five consumer packages | exit 0 |
| `typecheck` × 6 (`spec`, `connector-rest`, `connector-openapi`,
`connector-mcp`, `connector-slack`, `service-automation`) | exit 0 |
| `test` × 5 consumer packages | 1786 passed |
| spec `src/integration src/conversions src/migrations` + the
migrate-sentence and cron pins | 569 passed / 16 files |
| spec `test:repo` | **600 passed** |
| full `pnpm build` + the re-derived 112-command gate sweep on this head
| **112 / 112**, and on the latest round with **zero** prerequisite
failures, because the full build ran first (earlier rounds had three
first-pass non-zeros, all `PREREQUISITE NOT MET` from unbuilt packages ⇒
read as NOT MEASURED, ⛔ never as failures, and re-run green after the
build) |
| the new retirement pin under `--project repo` | **15 passed** |
| `check:generated` | **15 of 15** green, `check:docs` and
`check:liveness` included |
| the 14 source audits `check:generated` names as not run | all exit 0,
each captured separately |
| `check:nul-bytes`, `check:cross-package-test-inputs`,
`check:adr-0087-registration`, `check:changeset-no-major` | exit 0 |

Every exit code above was captured before any pipe. The repo-wide gate
farm is CI's run, not this PR's local obligation.

## Acceptance notes

- **Scope deviation, declared.** The dispatch fenced `content/docs/**`
off. `content/docs/references/integration/connector.mdx` is an
**auto-generated** baseline whose gate (`check:docs`) is inside the
required `TypeScript Type Check` job, and it goes stale on this change
alone. Measured across all 19 open PRs (283 file rows, 0 unreadable):
**zero** hold that path, so the fence's stated reason — "open PRs hold
files there" — does not apply to it; the instrument discriminates,
returning `content/docs` rows for seven other PRs. It is regenerated
here, exactly as the sibling retirement objectstack-ai#19618 regenerates four of the
same tree's pages. No hand-written `content/docs/**` prose is touched,
and `skills/**` and `.claude/**` are untouched — this diff hits **no
governed surface**.
- **Scope, mechanically forced.** The tombstone types the key `never`,
so the four connector packages and `service-automation` must stop
writing it or the monorepo does not compile. Those paths are outside the
claim's declared file surface and are held by **zero** open PRs on the
same census.
- **The two-writer surface materialised as declared** — see the report.
- `packages/spec/vitest.repo-tests.json` gains one line: the new
tree-scoped absence pin's walk radius, which
`check:cross-package-test-inputs` demanded by name. No new glob; the
radius was already declared for this package.
- The `connectionTimeoutMs`-is-never-mapped pin in
`connector-fetch-policy.test.ts` is **kept** after the retirement,
deliberately: it is what makes a re-introduction as a silent alias onto
`timeoutMs` fail.
- `health.circuitBreaker` remains `dead` on this schema and is **not**
touched here — a different set of rows on the same ADR-0049 worklist.


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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ing its at-tier review (objectstack-ai#19993)

Fixes objectstack-ai#19973
Clause-②: no

## 维护者速读(草稿)

**改了什么**:把 `needs:contract-review` 恢复为「PR 在等达档契约复核」的可见标记。规则落在
`contract-review.md`
新增的一节(何时挂、何时摘、谁读,并写明没有任何检查读取它);`landing-operations.md`
里「子代理起不来」那一行原地改写,点名这个标记(行数不变);`ensure-pm-labels.sh`
加回这个标签的定义(新颜色,说明写明「只是标记,没有检查读它」)。

**为什么改**:您的原话「恢复 needs:contract-review,把这句原话写进一张 skills
车道的卡」,回答的是「只作等复核标记,不作闸门」那一问。上一班达档复核子代理连续被限流,12 个 CI 全绿的 PR
在等达档复核记录,只能靠翻座位贴才看得到;现在一个过滤 `is:pr is:open label:needs:contract-review`
就能列出来,交接也不必先读座位贴。

**风险与代价(含回滚)**:不新增任何门禁:没有 check、workflow、队列守卫或巡查脚本读它,落地仍只认 `## Contract
review` 记录,标记丢了或多挂了都不会放行或拦下任何 PR。代价是派发席每个条款② PR 多两次标签写(ACCEPT 时挂,PASS
时摘)。已逐个核对本仓会写 PR 标签的 workflow:今天没有任何自动机制会摘掉手挂的 PR 标签。回滚 = revert 本
PR;GitHub 上的标签对象不受影响。

**席位意见**:(留空,待席位填写)

**你要做的**:合并后请持有 `gh` 的人跑一次 `bash scripts/pm/ensure-pm-labels.sh
--reconcile`,把现存标签对象的颜色与说明对齐(它现在是 GitHub 自动建的灰色、说明为空)。本 PR 的受管路径全在
`.claude/**`(Tier S),由席位在达档复核 PASS 后落地,不等您点合并。

## Summary

The maintainer's instruction recorded on objectstack-ai#19973 — 「恢复
needs:contract-review,把这句原话写进一张 skills 车道的卡」, answering a question that
proposed 「只作等复核标记,不作闸门」 — brings `needs:contract-review` back as a
**visibility marker, never a gate**. Every layer that ruling record
5770886272 (letter B) retired stays retired: no queue-guard refusal, no
`--pair`, no double carrier, no independence pair. The enqueue gate
still decides on the `## Contract review` record alone, and nothing in
this diff reads the label.

## What changed — 3 files, +28 / -2

| path | change |
|---|---|
| `.claude/skills/pm-dispatch/references/contract-review.md` | a new
section, heading plus 5 rule lines (:30-:36); the :3 pointer now lists
it. 28 → 36 lines, ceiling 60 |
| `.claude/skills/pm-dispatch/references/landing-operations.md` | :13
rewritten in place to name the marker. 101 / 101 lines; that line goes
113 → 119 bytes |
| `scripts/pm/ensure-pm-labels.sh` | one main-repo row after
`needs:pack-smoke`: colour `bfdadc`, a 95-character `-d`, and a comment
block naming the label's readers |

The rule as landed (contract-review.md :32-:36):

- it is only a marker, not a gate. The PR is the single carrier; a copy
on the card is outside the rule and not required.
- **hang**: at ACCEPT, if either clause-② limb hits and no same-form
PASS is on file for the current head, the dispatching seat hangs it on
the PR in the same stroke.
- **clear**: when a same-form PASS is on file for the current head, the
seat that posts it clears the marker in the same stroke. When the PR
merges or closes, the dispatching seat clears it. **A FAIL does not
clear it.**
- **readers**: the maintainer's filter `is:pr is:open
label:needs:contract-review`, and each seat's patrol and handover.
- ⛔ no check, workflow, queue guard or patrol script reads it. Enqueue
recognises only the same-form record, and the marker being present or
absent changes no verdict.

landing-operations.md :13, before and after:

```text
- 子代理起不来 ⇒ 复核缺席,PR 留 draft 队列外等档;唯一旁路是维护者亲审,逐次为准。
- 子代理起不来 ⇒ 复核缺席,PR 带 `needs:contract-review` 留 draft 队列外;旁路仅维护者逐次亲审
```

The line keeps three facts: the review is absent; the PR stays draft,
outside the queue; and the maintainer's own review is the only bypass,
per instance (唯一 … 逐次为准 → 仅 … 逐次). 「等档」 is carried by the marker itself,
which already says the PR awaits its at-tier review. Keeping 「等档」 as
well measured 125 bytes, over the 120-byte cap.

## Durability — what removes a PR label on this repo today (read at base
`ba77509eee`)

- `pr-automation.yml` job `pr-size` → `scripts/pr-labels.mjs --size`. It
POSTs the computed `size/*` label, then sends a targeted DELETE only for
stale `size/*` labels (`planSizeWrites` loops over the size family and
nothing else). The job is skipped on `labeled` / `unlabeled` / `edited`.
- `pr-automation.yml` job `auto-label` → `scripts/pr-labels.mjs
--paths`. It only POSTs: 「Path labels are ADD-ONLY … So this half issues
POST and has no DELETE at all」 (:225-:227). The keys in
`.github/labeler.yml` are documentation, `protocol:*`, ci/cd,
dependencies, tests and tooling; none is a `needs:*` label.
- `lint.yml` runs `node scripts/pr-labels.mjs --self-test`, which pins
that no write plan emits a PUT. It also runs `node
scripts/check-whole-set-label-write.mjs`, which reds on a whole-set `PUT
/issues/{n}/labels` in any spelling anywhere in the repo. That verb
(third-party labelers, and a `labels` field written through MCP) is what
removed this label in the gate era.
- `stale.yml` (`actions/stale`) removes only its own `stale` label. It
closes a PR after 37 idle days, and a close is already a clear trigger
in the rule.
- `half-state-patrol.yml` runs `sweep-closed-cards.mjs --write`, which
strips `PM_RESIDUE_LABELS` (the `pm:*` state labels) from **closed
cards** only.
- `merge-queue-triage.yml` adds labels to its anchor issues only.
`fleet-write.yml` runs only the ops a seat names.
- objectui's labeler runs with `sync-labels: true`, but that is
objectui's. This label is created in this repo only.

⇒ **Today no mechanism on this repo removes a PR label that a seat hung
by hand.** The live carriers' event history agrees. Every labeled or
unlabeled event for `needs:contract-review` on PR objectstack-ai#19962, PR objectstack-ai#19968,
objectstack-ai#19955 and objectstack-ai#19953 is by `objectstack-fleet[bot]`, that is, by a seat.
The only removal pair (PR objectstack-ai#19962 at 11:27:07Z, objectstack-ai#19953 at 11:27:41Z) was
the spec seat's own stroke after an at-tier FAIL (comment 5813182458,
「Carriers stripped on the PR and on this card」), and both were hung
again at 12:14Z. Losing a marker is also the safe failure: a waiting PR
drops out of the filter, but nothing is released, because the queue
guard reads the record.

## Live carriers at dispatch (read 2026-09-24T14:46Z) — ⛔ this PR
changes no label on any of them

| carrier | kind | what the rule says |
|---|---|---|
| objectstack-ai#19962 | PR, draft, head `22c9473c86` | path limb hits
(`packages/spec/src/security/rls.zod.ts`). The marker stays until a
same-form PASS is on file for its current head; whoever posts that PASS
clears it. Under the rule, the 11:27Z clear after the FAIL would not
happen: a FAIL leaves the marker on. |
| objectstack-ai#19968 | PR, draft, head `b05a88136d` | path limb hits
(`packages/spec/src/ui/view.form.ts`). Same as above. |
| objectstack-ai#19955 | card | a card copy is outside the rule and not required. What
happens to it is for the spec seat that hung it. |
| objectstack-ai#19953 | card | same as objectstack-ai#19955. |

Aligning these four carriers is the dispatching seat's closeout step
once the rule is on `main`, as the claim amendment on the card says. It
is not part of this PR.

## Four scripts that still name the label as retired — unchanged, on
purpose

`scripts/pm/check-half-states.mjs` :11927 and :18308,
`scripts/pm/check-skill-line-ratchet.mjs` :448 and :830,
`scripts/pm/check-widening-tells.mjs` :673, and
`scripts/pm/clause2-line.mjs` :11 and :306. Each one describes the
**gate role** (a half-state row that patrolled it, a raise provenance, a
dated census line, the ruling's summary, a measured incident). That role
is still retired, so every sentence stays true. None of them reads the
label, and this PR does not make any of them a reader. `AGENTS.md`,
`SKILL.md`, `state-machine.md` and `.claude/agents/os-dev.md` are
untouched too; the claim excluded them.

## Acceptance notes

- The filer's reading on the card calls objectstack-ai#19955 and objectstack-ai#19953 PRs. The REST
objects carry no `pull_request` key, so they are cards; the claim
amendment already reads them that way.
- 40 cards and PRs that are no longer open still carry the label from
the gate era (for example PR objectstack-ai#19666 and PR objectstack-ai#19618; 44 items in all, 4 of
them open). The filter reads `is:open` and no script reads the label, so
they are inert. Nothing in this PR touches them.
- Governed PRs on either landing tier, this one included, also wait on
an at-tier `## Contract review` record. They are outside the restored
marker's population, which is only the two clause-② limbs, the
population the retired label had. Whether to widen it is left to the
seat as an open question in the report.
- `SKILL.md`'s state-model table does not list the marker. It is a PR
label, not a card state; its rule lives in `contract-review.md`; and
`SKILL.md` is outside this PR's surface.

## Pending after merge — the seat's, not this PR's

- Someone holding `gh` runs `bash scripts/pm/ensure-pm-labels.sh
--reconcile` once. The live object is `ededed` with an empty description
(read 2026-09-24T14:46Z), and create-if-missing never changes an object
that already exists.

## Tests — on `84f4580e`

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; three-dot vs merge base
`ba77509ee`) derived 32 commands. The dispatch named four more: `node
scripts/check-skills-token-ratchet.mjs`, `node
scripts/pm/check-governed-queue-guard.mjs --self-test`, `pnpm
check:pm-expected-skips` and `pnpm check:pm-governed-prose`. **All 36
exit 0**, each exit code captured before any pipe. `--ran`
reconciliation: 「32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN」.
- `pnpm check:pm-label-desc-cap`: 「39 label descriptions … all ≤100
characters (longest: 100, tooling)」 (38 on base).
- `pnpm check:pm-skill-ratchet`: 「contract-review.md is 36 lines
(ceiling 60; headroom 24)」 and 「landing-operations.md is 101 lines
(ceiling 101; headroom 0)」. All lines are ≤120 bytes; :3 is at exactly
120.
- `pnpm check:pm-skill-id-lint`: 「34 file(s) clean」. `pnpm
check:skill-frame-sync`, `pnpm check:doc-authoring`, `pnpm
check:pm-governed-prose` and `pnpm check:nul-bytes` are green.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first exited **3** (PREREQUISITE NOT MET: `@objectstack/formula` /
`@objectstack/lint` not built). That run measured nothing. After `pnpm
exec turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` under `scripts/pm/os-verify-lock.sh`
(VERDICT command-exit 0), the rerun exited 0.
- `bash -n scripts/pm/ensure-pm-labels.sh` exits 0. A fake `gh` on PATH
ran `ensure-pm-labels.sh --reconcile`, exit 0: the new row issued `label
create needs:contract-review -R objectstack-ai/objectstack -c bfdadc -d
…` and the matching `label edit … --color bfdadc --description …` with
the same string.
- `node scripts/pm/check-governed-merges.mjs --test` on the three paths
returns GOVERNED, **Tier S** (`.claude/** ×2`);
`scripts/pm/ensure-pm-labels.sh` is not on the register.
- A self-scan for control bytes on the three files finds none.
- Not run locally: no package is touched, so there is no build closure
and no package test or typecheck. `pnpm lint` and the CI-only families
(the shard attestation, the test-completeness reader and the type-check
lanes) are left to CI.

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

---------

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retire tenancy.organizationField from the authorable surface — one platform table's fact, not customer configuration

3 participants