Skip to content

feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips (#12497 class rule) - #12845

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-12840-retired-defaulted-key-tolerance
Aug 28, 2026
Merged

feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips (#12497 class rule)#12845
hotlong merged 1 commit into
mainfrom
claude/issue-12840-retired-defaulted-key-tolerance

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Fixes#12840
Part of objectstack-ai/cloud#1653 (ruling record: objectstack-ai/cloud#1685 — maintainer 2026-08-28, the three-question review; objectstack-ai/cloud#1672 is the blocked consumer). cloud#1685 remains open.

What this implements (ruled semantics, not re-adjudicated)

A RETIRED key that carried a schema default is refused only when it carries a NON-default value:

  • allowRestore: false / allowPurge: false — the defaults the published @objectstack/spec 17.x toolchain materialized into every built artifact's permission entries (75 occurrences in the measured HotCRM artifact; its sources declare neither) — now parse as inert residue and are STRIPPED: the normalized output carries neither key, and parse → serialize → parse is a fixpoint on the clean shape (no re-emission).
  • allowRestore: true / allowPurge: true keep the spec: retire the allowRestore / allowPurge permission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2) #12497 refusal byte-for-byte: same guidance string (pinned equal to the tombstone's own declared prescription), same issue shape (expected: 'never', code: 'invalid_type', located path). The guidance strings are untouched by this diff.

The reusable helper (name and placement argued)

acceptRetiredDefaultResidue(schema, residue) in packages/spec/src/shared/retired-key.ts — beside retiredKey(), because this is the second half of the same doctrine: the tombstone answers authored values, the residue stage answers the emitted default a released toolchain froze into artifacts. residue is the retired default captured as a literal at retirement time (the schema no longer has the default — the tombstone replaced it — so nothing live can be re-read; compared by identity, so falsy near-misses like 0 / empty string / null still refuse). The next defaulted-key retirement reuses the helper with its own literal.

Mechanism: a z.preprocess stage ahead of the closed shape — the shipped ViewMetadataSchema / translation retired-dialect precedent, whose pipe orientation every schema walker already resolves via pipeAuthorableSide (#4488/#5074/#5317). The strip is copy-on-write and runs before the per-key tombstone, so a non-default value never touches the stage and the refusal cannot drift. EffectiveObjectPermissionSchema (the wire clone) extends the same closed base and gets the same stage — a server still on the published 17.x toolchain emits the residue on the wire too.

The card's boundary — the seam, examined

The card reserved a stop if the tombstone's seam cannot distinguish authoring from artifact parsing. Examined: the Zod parse sees no provenance (JSON carries none), and the ruling itself discriminates by VALUE, not by path — so the reserved stop does not trigger. What the authoring surface keeps, distinguishably and pinned:

  • tsc: z.input stays never for both keys — writing either in TypeScript source fails to compile exactly as spec: retire the allowRestore / allowPurge permission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2) #12497 ruled (pinned with a compiled ts-expect-error in permission.test.ts; the file is in the test-typecheck program and not in its debt ledger — verified via --listFiles, 2 hits).
  • Published surface: the authorable-surface artifact still carries both [RETIRED] rows (verified in authorable-surface/security.json after regen), and the JSON-schema manifest is unchanged — check:generated reports all 14 artifacts up to date with zero regeneration needed.
  • Parse of any authored (non-default) value: the untouched refusal.

What is deliberately tolerated everywhere (source JSON included): exactly the one value the released toolchain emitted, which the maintainer ruled behaviourally identical to absence for the key's entire history.

Notice posture

Zero notices, by design — the ruled bound is "at most low-noise, never per-occurrence storms". A spec schema parse has no notice channel, and inventing one for this would be a new side channel; the loud channels for authored sources (tsc, the D2 conversion permission-allow-restore-purge-removed, os migrate meta) are untouched. Pinned: a 38-entry (76-residue-key) permission set parses with console spies asserting no output. The D2 conversion stays retiredFromLoadPath (migrate-meta-only) — it rewrites sources including true, which must keep refusing at load; its docblock now records the split.

Tests

  • Acceptance matrix (security/permission.test.ts): the published-toolchain entry shape from cloud#1685 parses and carries neither key; each key strips independently; true refused per key with the byte-for-byte pin; falsy near-misses refused; round-trip fixpoint; wire-clone tolerance; no-storm spy test.
  • Composed-artifact door: ObjectStackDefinitionSchema parse with the residue at the exact measured coordinate permissions[5].objects.crm_campaign_member.allowRestore — accepted and normalized.
  • Helper contract (shared/retired-key.test.ts, synthetic schema with a boolean and a string default): reuse-shaped, captured-literal identity semantics, tombstone refusal byte-equality, strictness not loosened through the stage, walker-orientation pin (pipe with OUT = object).
  • Suite: pnpm --filter @objectstack/spec test → Test Files 433 passed | 4 skipped; Tests 11556 passed | 22 skipped. pnpm --filter @objectstack/spec typecheck → clean (src + scripts + test layer, check:test-typecheck: OK).

Ablation (disk-proven): mutated the helper's pipe to a pass-through (strip disabled; injected marker grep-counted 1, original line 0 — the mutation landed), reran the two suites: 9 failed | 61 passed, the failures exactly the residue-acceptance/round-trip/no-storm legs, every true-refusal leg green. Restore via git checkout HEAD with an EXIT/INT/TERM trap on absolute paths; proven by empty git diff HEAD and git hash-object equal to the HEAD blob. Resolution path: spec's own vitest resolves the subject from src/ via relative imports (no dist in the loop), so neither leg needed a rebuild — stated per the ablation contract.

Gates (all run at 8599de8, unlocked runs — no flock on this host)

check:generated sweep: "All 14 generated artifacts are up to date" (api-surface, authorable-surface + JSON schemas + authorable-defaults, docs, spec-changes, upgrade-guide, migration-registry among them). The dispatch-gates derivation (no paths passed; stderr names this repo at 8599de8) yielded 22 families; all green: authorable-surface, docs, empty-state, liveness, skill-refs, spec-changes, strictness-ledger, changeset-gate-self-tests, cross-package-test-inputs, doc-authoring, doc-formula-expressions (after building its @objectstack/formula + @objectstack/lint prerequisites — first runs were PREREQUISITE NOT MET, not measurements), merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, pm-half-states, published-files, query-options-erasure, slot-lookup, spec-parsed-alias, test-source-alias, type-source-resolution, plus check:nul-bytes.

Changeset: minor for @objectstack/spec (launch-window rule; widens acceptance, not breaking — no ADR-0087 disposition marker owed, the gate judges declared-breaking changesets only).

Consumer section — what cloud #1672 must re-verify after the bump

  1. Bump .objectstack-sha to a SHA that includes this merge (it is an ancestor question, not a version-number one — the 17.2.0 number does not move here).
  2. Re-run the red step: apps/objectos-ee's HotCRM multi-tenant acceptance. The composed artifact built from the existing .hotcrm-sha (published spec 17.0.0/17.1.0, emitting both keys as false, 75 occurrences) must now boot — the step that died at first request (fetch failed / ECONNREFUSED) should pass without touching .hotcrm-sha and without stripping keys cloud-side.
  3. Negative control worth keeping: an artifact carrying true for either key must still refuse with the spec: retire the allowRestore / allowPurge permission props (ruled 2026-08-26; M2 anchor stays open, keys return with M2) #12497 guidance — the acceptance's subject stays "the shipped artifact boots", not "the parse tolerates everything".
  4. Boot logs: no per-entry warning storm (the strip is silent).

What was delivered here is the schema-level matrix + round-trip convergence + a composed-artifact-door reproduction at the exact measured path (ObjectStackDefinitionSchema); a full HotCRM artifact boot was not reproduced locally — that is cloud#1672's acceptance step itself.

Session: https://claude.ai/code/session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6

Generated by Claude Code

…ses as inert residue and strips (#12840)
A RETIRED key that carried a schema default is refused only when it
carries a NON-default value (maintainer ruling 2026-08-28, recorded on
objectstack-ai/cloud#1685). The retired default — materialized by the
published 17.x toolchain into every built artifact's permission entries —
parses as inert residue and is STRIPPED before the closed shape sees it;
the normalized output carries neither key and a round-trip converges.
allowRestore/allowPurge: true keep the #12497 refusal byte-for-byte.
Ships as the reusable class helper acceptRetiredDefaultResidue()
(shared/retired-key.ts): a z.preprocess stage ahead of the closed shape
(the ViewMetadataSchema / pipeAuthorableSide precedent), residue captured
as literals at retirement time, copy-on-write, silent by design (75
occurrences in the measured HotCRM artifact would otherwise be a storm).
Nothing is un-retired: z.input stays never (tsc channel pinned), the
authorable-surface/JSON-schema artifacts keep the [RETIRED] rows, and
the D2 conversion stays migrate-meta-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 9 documentable anchor(s). ⚠️5 changed file(s) yielded no anchor (packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts, packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts, …), 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/permissions/permission-metadata.mdx(via allowPurge (symbol), allowRestore (symbol))
  • content/docs/permissions/permission-sets.mdx(via allowPurge (symbol), allowRestore (symbol))
  • content/docs/permissions/permissions-matrix.mdx(via allowPurge (symbol), allowRestore (symbol))
  • content/docs/protocol/objectql/security.mdx(via ObjectPermissionSchema (symbol), allowPurge (symbol), allowRestore (symbol))

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

  • content/docs/releases/v12.mdx(via allowPurge (symbol), allowRestore (symbol))
  • content/docs/releases/v17.mdx(via EffectiveObjectPermissionSchema (symbol), ObjectPermissionSchema (symbol))

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
  • 5 changed file(s) yielded no anchor (packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowPurge.ts, packages/spec/src/migrations/entries/retired-keys/18.security__EffectiveObjectPermission__allowRestore.ts, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 126 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 f907fbe9ed62c68d3de1a8a49d5ac3b9ba13abfcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 49e328955c342944dab31024e1c3d962e624d04e — the merge of head 8599de8587453c239ef18a207b993aed6a5ce12b into base f907fbe9ed62c68d3de1a8a49d5ac3b9ba13abfc, 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 49e328955c342944dab31024e1c3d962e624d04e && git checkout 49e328955c342944dab31024e1c3d962e624d04e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f907fbe9ed62c68d3de1a8a49d5ac3b9ba13abfc 8599de8587453c239ef18a207b993aed6a5ce12b && git checkout -B drift-repro f907fbe9ed62c68d3de1a8a49d5ac3b9ba13abfc && git merge --no-ff 8599de8587453c239ef18a207b993aed6a5ce12b
node scripts/docs-audit/affected-docs.mjs --json f907fbe9ed62c68d3de1a8a49d5ac3b9ba13abfc

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

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 28, 2026
@hotlong
hotlong marked this pull request as ready for review August 28, 2026 03:23
@hotlong
hotlong added this pull request to the merge queueAug 28, 2026
Merged via the queue into main with commit 1ffee51Aug 28, 2026
41 checks passed
@hotlong
hotlong deleted the claude/issue-12840-retired-defaulted-key-tolerance branch August 28, 2026 03:44
os-zhuang pushed a commit that referenced this pull request Aug 28, 2026
…sal surface under the #12845 residue ruling
Main landed acceptRetiredDefaultResidue (maintainer ruling 2026-08-28): a
retired DEFAULTED key's emitted default parses as inert residue and strips
silently at the schema layer; only a NON-default value keeps the #12497
tombstone. The real 17.1-built fixture carries exclusively the emitted
default (150 x false, premise-guarded), so the old current-version refusal
pin now describes a shape the ruled contract accepts - the merge queue
measured exactly that on its merge tree.
The pin is re-aimed, not weakened: current-version + NON-default value
(true, authorable on the 17.1 surface, never emitted by a default) still
refuses with the tombstone and prescription; and a new pin holds the other
half of the ruling at the door - current-version + pure residue parses
clean with the keys stripped by the SCHEMA layer while this door's
versioned window stays closed (no conversion notice), keeping the door's
amnesty versioned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVYmuhHW6qZmNBqciaS7BN
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@hotlong@os-zhuang