Commit 42339e2
feat(scripts): refuse a non-factory
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 | **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
#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: "#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.
*.object.ts declaration by name (ruling item 2 + census) (#19720)1 parent 7e0bfce commit 42339e2
1 file changed
Lines changed: 323 additions & 6 deletions
0 commit comments