Skip to content

feat(scripts): refuse a non-factory *.object.ts declaration by name (ruling item 2 + census) - #19720

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-17418-object-ts-authoring-shape
Sep 22, 2026
Merged

os-warren merged 3 commits into
mainfrom
claude/issue-17418-object-ts-authoring-shape

Conversation

@os-warren

@os-warren os-warren commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

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 the domain:cli seat 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

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 nodomain: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 #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 bce8e230a48d -> 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 (bce8e230a48d) 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:99os 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: "os init -t app scaffold does not compile on CLI 17.0.0 — the template's own object trips the security-owd-unset author-time rule #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:255expect(scaffold).toContain('const orderLine: Data.ServiceObject = {').
  • packages/cli/test/generate-emission-parses.test.ts:148expect(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, falsetrue, 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

`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>
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
os-warren marked this pull request as ready for review September 22, 2026 12:47
@os-warren
os-warren enabled auto-merge September 22, 2026 12:47
@os-warren
os-warren added this pull request to the merge queue Sep 22, 2026
Merged via the queue into main with commit 42339e2 Sep 22, 2026
46 of 47 checks passed
@os-warren
os-warren deleted the claude/issue-17418-object-ts-authoring-shape branch September 22, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants