feat(scripts): refuse a non-factory *.object.ts declaration by name (ruling item 2 + census) - #19720
Merged
Merged
Conversation
`check:keyed-text-bounds` finds object declarations by `CREATE_CALL` (`ObjectSchema.create(`) and nothing else, so a declaration written as a plain annotated literal is invisible to it. One backstop existed -- a file yielding zero declarations and zero refusals is refused -- but it 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. Measured on a two-file control tree: a literal `ctrl_hidden` keying a UNIQUE index on an unbounded `text` column -- the exact defect this gate exists to catch -- was swept, parsed as 1 object, and reported clean. The authoring-shape scan now runs independently of the factory parse, over every `*.object.ts`, and refuses a literal-shaped declaration BY NAME: "use `ObjectSchema.create`". Four signals, stated in the header: a `ServiceObject` annotation, a `satisfies ServiceObject`, the file's default export, or a literal `name:` beside a `fields:` at column 0. The zero-declarations backstop steps aside when the shape scan already named the reason, so a literal-shaped file gets one finding that prescribes the factory rather than a second one inviting the parser to be widened. Counts are unchanged over the tree (112/117/250/592/147) and the pass line now reports the authoring-shape reading beside them. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…verdict Ablating the shape scan left the pass line claiming "0 literal-shaped declarations ... every declaration is `ObjectSchema.create`" over a tree that held `ctrl_hidden.slug` -- an unbounded text column keyed by a UNIQUE index -- judged by nothing. The tree carries no literal-shaped declaration, so that zero has no floor under it and a dead detector prints the identical line. State what was scanned instead, and name `--self-test` as the liveness proof, so a green log cannot be read as a verdict the detector can no longer back. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 22, 2026
The roster pinned this battery at 12 while it registers 15, so any three cases could be deleted and the floor would still pass -- including both MIXED cases, which are the only ones that exercise the hole the rule was written to close. Pin it at 15, measured at this head. The `insideFunction` case pinned nothing: its fixture spelled `name: n`, an identifier, so the structural signal was unreachable from it whatever the column-0 guard did. Ablating that guard left `--self-test` green. With `name: 'b'` the case reaches the guard and the ablation goes red. The refusal text named the conversion but not the import: the `os init` shape imports only `* as Data`, so `ObjectSchema` is not in scope for the author being told to call it. The message now says so. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
os-warren
marked this pull request as ready for review
September 22, 2026 12:47
os-warren
enabled auto-merge
September 22, 2026 12:47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #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 thedomain:cliseat and is listed below with its real file surface.Clause-②: no
Ruling: comment 5644350230 (director seat, decision batch #122 item 1, maintainer 「同意」 2026-09-12).
The lane split, and what is NOT here
TEMPLATESinpackages/cli/src/commands/init.tsemit the factory shape;content/docs/deployment/cli.mdx:1323describes itdomain:clisibling; surface measured below*.object.tsnot using the factory is refused by name — "useObjectSchema.create"No file under
packages/cliis touched.content/docs/deployment/cli.mdxis not touched.packages/platform-objects/src/identity/sys-api-key.object.ts(held by #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 initstill 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/mainat4fba5036f2:walkObjectFilescollects files whose name ends.object.ts, repo-wide minusSKIP_DIRS. Measured: 112 files, zero of them underpackages/cli(git ls-files | grep '\.object\.ts$'also returns 112, so nothing untracked is hiding either).TEMPLATESare string literals insidepackages/cli/src/commands/init.ts— a.tsfile, not a*.object.tsfile. The walk matches by filename, so the template strings are never read by this gate, in any shape.os.tmpdir(), never into the repo tree (mkdtempSync(join(tmpdir(), …))throughoutpackages/cli/test/). No test run can transiently materialise a literal-shaped*.object.tsinside the walk, and the tree carries no ignored one either.os initemits into a user's project, which does not carry this repo'sscripts/.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 onobjects.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:
mixed.object.tsproduced nothing — and the literal declaration inside it (ctrl_hidden) keys a UNIQUE index on an unboundedtextcolumn, 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:Four signals, published in the file header because a source scan sees only the spellings it knows: a
ServiceObjectannotation (any indentation), asatisfies ServiceObject(any indentation), the file's default export (top level), and a literalname:beside afields:(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.tsin the same walk, list every top-level binding of an object literal or of a call, plus everyexport default, then subtract the ones whose initializer isObjectSchema.create(. Whatever is left is a candidate for hand triage. It does not depend on knowing the spellingData.ServiceObject.Result:
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 — theobjectrow ofDEFAULT_METADATA_TYPE_REGISTRY(packages/spec/src/kernel/metadata-plugin.zod.ts:725) declaresfilePatterns: ['**/*.object.ts', '**/*.object.yml', '**/*.object.json']. Blind to: declarations in files not carrying that suffix, the.yml/.jsonpatterns (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: zeroexport defaultand zerosatisfiesoccurrences 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.mjscarried the mutation, so the anchor hit and the blob move are its own verdict rather than a remembered claim: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-testas the liveness proof (commit 2).A new
--self-testbattery,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 — andSELF_TEST_BATTERY_FLOORmoves 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 ownregistered 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 isMIXED: 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.mjssits inside no workspace package directory (checked against every tracked*/package.jsondirectory), and npm packs relative to the package directory, so it cannot be packed. The repo-root manifest isprivate: true. No non-private package lists ascriptsdirectory infiles[]. Positive control:packages/specis non-private and ships["dist","json-schema","liveness","prompts","llms.txt","README.md","src/**/*.zod.ts","CHANGELOG.md","api-surface","spec-changes.json"]— a realfiles[]exists and does not reach repo-rootscripts/. The new symbolliteralShapeDeclarationsoccurs in exactly one file, that one.skip-changesetwas applied by the seat, with its own measurement recorded at comment 5775287713 (route 1: the one changed file is repo-rootscripts/, inside no package directory; root manifestprivate: true; 77 manifests censused forfiles[]escape hatches, zero found, with a lit control). The staleCheck Changesetred 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 gemitting the factory shape from a published package. So the ruling's changeset obligation attaches to the half that publishes, i.e. thedomain:clisibling PR, not to this one.What item 1 actually requires — real files
The seat files the
domain:clicard from this list. Measured, not guessed:Emitters (behaviour change):
packages/cli/src/commands/init.ts:649and:744— the twoTEMPLATESentries forsrc/objects/__name___item.object.ts, both emittingconst ${toCamelCase(namespace)}Item: Data.ServiceObject = {.packages/cli/src/commands/generate.ts:99—os generate objectemitsconst ${toCamelCase(name)}: Data.ServiceObject = {. The ruling's item 1 does not name this file, and it must. Its own docblock declares the coupling: "os init -t appscaffold does not compile on CLI 17.0.0 — the template's own object trips thesecurity-owd-unsetauthor-time rule #9666 took it once for theos inittemplates, 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 isData.ServiceObject".packages/cli/test/init.test.ts:493— reads the scaffoldedmy_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.mjskeepscreate-objectstack's bundled template'spnpm/typescriptranges in lockstep withpackages/cli/src/commands/init.ts(POLICY_SOURCE). It syncs version ranges, not declaration shape — but the sibling should re-run it, becausecreate-objectstack's bundlednote.object.tsis 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.tsfile, 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 theClause-②: noreading this PR carries.packages/services/service-datasource/src/external-datasource-service.ts:931emitsconst ${definition.name}: ServiceObject = {as the object draft thatos datasource introspect --out objects/x.object.tswrites into a user's project (ADR-0015). A third scaffolder door, server-side. Pinned atpackages/services/service-datasource/src/__tests__/external-object-draft-os-build.test.ts:138.packages/metadata/src/serializers/typescript-serializer.ts:23emitsexport const metadata: ServiceObject = ${jsonStr};for thetypescriptmetadata format.Noted, not filed:
provenanceLine's record still reads-1/-1/-5/-2/-4againstMEASURED.reffa5d137ab0, 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.--self-testand the gate are wired as one pair (package.json:183,.github/workflows/lint.yml:731-732, inside the requiredLint & Repo Gatesjob, 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.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwere run atbeef275c08and again in full at4d034cd2f9,--ranreconciling both as 31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN, every row carrying its exit code, all 0.7adf1e0a6creports re-running all 31 there with the same reconciliation, and reports that its first sweep read 5 at exit 3 — PREREQUISITE NOT MET, a missingnode_modulesin a fresh worktree — which were NOT MEASURED and never green untilpnpm installand 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.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 oneeslint.config.mjs, which per its own comment at line 327 "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) 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-widepnpm lintwas NOT MEASURED in that round —scripts/pm/os-verify-lock.shreturnedqueue-timeout (exit 99)after 9m00s, never acquiring, holder pid 19251 — neither green nor red. ⭐ That NOT MEASURED is now superseded by the delivering round at7adf1e0a6c, which reportseslint . --no-inline-config --format json:: exit 0 over 7,005 files, 0 errors, 0 warnings.7adf1e0a6c, read by the SEAT by job conclusions, latest run per check NAME — ⛔ never an aggregate roll-up: 31 names, 23success, 8skipped, 0failure, 0in_progress. All five required checkssuccess: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.insideFunctioncase now pins the guard it is named for. Ablating the column-0 guard (m[1].length === 0, false→true, false): at4d034cd2f9the self-test was exit 0 / PASS / 0 failures; at7adf1e0a6cit is exit 1 / FAIL / 1 failure, the failing case being exactly that one. Both legs restored, proven by an emptygit status --porcelainand an emptygit diff HEADrather 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