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 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 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: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: "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: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

`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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ape for object drafts (objectstack-ai#19776)

Fixes objectstack-ai#19723

Clause-②: no — this pulls the emitter back onto a shape a ruling already
declared; it adds no exported symbol and no key on any published
payload. Verified: `git diff` touches no `packages/spec` path and adds
no `export` to `@objectstack/service-datasource`'s public surface.

## The defect, re-measured at `origin/main` (e99a14c)

`renderObjectSource`
(`packages/services/service-datasource/src/external-datasource-service.ts`)
emitted the annotated-object-literal form — `import type { ServiceObject
} …`, an unexported `const NAME : ServiceObject = { … }`, closed by
`export default NAME`.

Ruling `5644350230` (director seat, decision batch objectstack-ai#122 item 1,
maintainer 「同意」 2026-09-12) makes `ObjectSchema.create({ … })` the one
authorised shape for a `*.object.ts`:

> Derived first from the long-term axis: one authoring shape, validated
against the protocol at the moment of definition. The factory parses the
object against `ObjectSchema` when the file is evaluated, so an error
surfaces where it was written; the typed literal defers everything to a
build the author may never run.

Both premises on the card hold as written — I re-read them on
`origin/main` rather than inheriting them:

- the shape at `:931` is the annotated literal (confirmed);
- the function's own docblock at `:857` reads 「Render a reviewable
`*.object.ts` source string for an object draft」 (confirmed), and `os
datasource introspect --out objects/wh_order.object.ts` is the command's
published example, so the bytes are destined for a committed file the
ruling governs.

## What changed

One emitter, three lines of rendered output:

- `import type { ServiceObject } from '@objectstack/spec/data';` becomes
a **value** import of `ObjectSchema`. `import type` is elided at compile
time, so a type-only import beside a factory call would leave a module
that throws on its own first line — this is the half a shape-only fix
silently gets wrong.
- `const NAME : ServiceObject = {` becomes `export const NAME =
ObjectSchema.create({`, closed with `});`.
- `export default NAME;` is dropped. The export is named only, which is
what the scaffolded barrel re-exports
(`packages/create-objectstack/src/templates/blank/src/objects/index.ts`
is `export { Note } from './note.object.js';`). A second export form in
a generated file is the parallel shape the ruling closed.

Both authored comment blocks the card names are preserved byte-for-byte:
the remote-primary-key tombstone and the ADR-0028 unprefixed-name TODO.
The renderer's docblock moved with the code — it no longer claims `tsc`
over the string is the whole acceptance instrument, and it names the pin
that now holds the other half. The `REMOTE_PRIMARY_KEY_COMMENT`
docblock, which described the emitted file as "annotated", was corrected
in the same edit.

Real generated output, from `generateObjectDraft('warehouse',
'fact_orders')` with a namespace resolved and a remote primary key
reported:

```ts
// Generated by `os datasource introspect` (ADR-0015). Review before committing.
import { ObjectSchema } from '@objectstack/spec/data';

export const wh_fact_orders = ObjectSchema.create({
  name: 'wh_fact_orders',
  label: 'Fact Orders',
  datasource: 'warehouse',
  external: { remoteSchema: 'mart', remoteName: 'fact_orders' },
  // Remote primary key: order_id
  // Preserved as a COMMENT because 'ServiceObject' has no authorable key for a
  // federated object's remote primary key (objectstack-ai#11000): 'fields.[f].primaryKey' is
  // not part of the field schema, so emitting it produced a draft that neither
  // 'tsc' nor 'ObjectSchema' accepted. Nothing below reads this line.
  // It names the column(s) THIS DRAFT WAS GIVEN as the key. For a COMPOSITE key
  // some drivers report only the first column (objectstack-ai#10997), so treat the list as a
  // lower bound and check it against the remote table before relying on it.
  fields: {
    order_id: { type: 'text' },
    customer: { type: 'text' },
    placed_at: { type: 'datetime' },
    geom: { type: 'text' }, // REVIEW: unrecognised remote type — defaulted to 'text', verify
  },
  // Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
  // requires this to be an authored decision rather than an accident — the
  // `security-owd-unset` author-time rule refuses an object without it, so a
  // draft that omitted it could not compile. 'private' is the rule's own
  // recommended default: owner + explicit shares. Widen it deliberately.
  sharingModel: 'private',
});
```

(The remote-key block renders `fields.[f].primaryKey` with angle
brackets in the real output; they are spelled with square brackets here
only because this body is stored through an API that rewrites short
angle-bracket fragments.)

## The pin — form AND round-trip, because the form alone can be
satisfied by a broken file


`packages/services/service-datasource/src/__tests__/external-object-draft-authorised-shape.test.ts`
(new, 12 cases). It is not a snapshot:

1. **form** — asserts the value import, `export const wh_customers =
ObjectSchema.create({`, the call's closing `});`, and — separately — the
**absence** of each piece of the refused form by name (`: ServiceObject
= {`, the `import type` of `ServiceObject`, the unexported `const`,
`export default`). A pin that only asserts the new spelling cannot say
the old one left.
2. **round-trip** — strips the import line, rewrites the single named
export to a `return`, and evaluates the emitted module body through the
**real** `ObjectSchema` from `@objectstack/spec/data` with `new
Function`. That is the same factory call the committed file makes on the
author's machine; if `create()` would throw there, it throws here. The
evaluated value is asserted equal to
`ObjectSchema.parse(draft.definition)`, so the rendered file and the
structured definition are proven to describe one object.
3. **negative control** — the same harness is fed a source with one
unknown top-level key spliced in and is required to throw. A round-trip
that cannot fail is not a measurement.

The round-trip earned its keep on the first run: it failed because
`external.writable: false` is applied by the schema's own default, which
is standing evidence that the file really parsed rather than an object
literal being handed back. That key is now asserted explicitly instead
of the assertion being loosened.

`external-object-draft-os-build.test.ts` had the only other shape
assertion; it moved to the factory spelling. Its sibling
`not.toContain('_customers:')` leg is replaced rather than deleted: the
old spelling read the annotated literal's `const _customers:` and goes
**vacuous** once the type annotation leaves the file, and a bare
`not.toContain('_customers')` cannot replace it because the no-namespace
TODO block legitimately renders that substring. It is now
`toContain('export const customers = ObjectSchema.create({')` plus
`not.toContain('export const _customers')`.

## Lane split — it stays in this lane

The card and ruling item 4 warn that the door is a `packages/cli`
command. Measured: `packages/cli/src/commands/datasource/introspect.ts`
is a pure pass-through — it POSTs to the draft route and writes
`draft.source` verbatim (`await writeFile(target, draft.source,
'utf8')`), with no knowledge of the shape. Its description, its two
`--out objects/wh_order.object.ts` examples and its `*.object.ts` prose
describe the destination, not the form, and all stay true. **No
`packages/cli` change is owed**, so no sibling PR is needed for this
card.

## Verification, all at `78a7783cd5`

| what | command | result |
|:--|:--|:--|
| dependency closure | `pnpm --filter
'@objectstack/service-datasource^...' build --concurrency=2` | `VERDICT
command-exit 0` |
| package tests | `pnpm --filter @objectstack/service-datasource test` |
`Test Files 34 passed (34)` · `Tests 693 passed (693)` |
| package typecheck | `pnpm --filter @objectstack/service-datasource
typecheck` | `VERDICT command-exit 0` |
| repo lint | `pnpm lint` (full repo, `eslint . --no-inline-config`) |
exit 0 |
| derived gate families | `node scripts/pm/dispatch-gates.mjs
--commands` then `--ran` | 61 derived, 59 run green, 0 unrun |

`tsc --listFiles` confirms the new test file is inside the package's own
tsc program (1 hit), so the typecheck above really covers it.

Two of the 61 families are **NOT MEASURED**, not green and not red:
`check:dual-build-cjs-loads` and `check:type-check-debt` both exit **3**
— `PREREQUISITE NOT MET` — because each reads built output for the whole
workspace ("Run `pnpm build` first. ⛔ This is NOT a pass: nothing was
measured."). That is a full-farm build this lane does not own; CI runs
both against a built tree.

## Acceptance notes

- `packages/cli/src/commands/init.ts` (two `TEMPLATES` sites) and
`packages/cli/src/commands/generate.ts` still render `Data.ServiceObject
= {`. Those are ruling item 1 / the `domain:cli` half, tracked as objectstack-ai#19722
— ⛔ deliberately not touched here, per ruling item 4's lane split.
- `check:keyed-text-bounds` selects its population by filename suffix
over the repo tree, and no `*.object.ts` file exists under
`packages/cli` or `packages/services`. The strings changed here are
template literals inside a `.ts` file, so the gate never reads them in
any shape; this PR neither depends on nor unblocks objectstack-ai#19720.

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

---------

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

Fixes objectstack-ai#19722

Clause-②: no

Ruling `5644350230` (director seat, decision batch objectstack-ai#122 item 1,
maintainer 「同意」 2026-09-12), item 1:
「`packages/cli/src/commands/init.ts` `TEMPLATES` emit the factory shape;
`content/docs/deployment/cli.mdx:1323` describes it.」 Item 3: 「the
changeset states how a user converts theirs (one mechanical rewrite:
wrap the literal).」 This is the `domain:cli` half; the spec/scripts half
landed as PR objectstack-ai#19720 (`42339e2f`). objectstack-ai#17418 remains open (it carries
`Blocked-by` on this card and is the spec lane's to move). objectstack-ai#19098
remains open (the other `generate.ts` card, serial behind this one).

## What changed

Both doors that write a `*.object.ts` now emit the one authorised shape,
`ObjectSchema.create({ … })`:

| door | before | after |
|:--|:--|:--|
| `os init -t app` / `-t plugin` (`TEMPLATES[…].srcFiles`) | `import *
as Data …` + `const myAppItem: Data.ServiceObject = { … };` | `import {
ObjectSchema } …` + `const myAppItem = ObjectSchema.create({ … });` |
| `os generate object` (`GENERATORS.object`) | `const orderLine:
Data.ServiceObject = { … };` | `const orderLine = ObjectSchema.create({
… });` |

- The emitted shape is exactly what the changeset's user rewrite
produces from the old one (wrap the literal, drop the annotation, import
the factory), so a scaffold and a converted file look the same.
- `ObjectSchema` is a **value** import: `import type` is erased at
compile time and the module would throw on first evaluation.
- The binding stays the file's **default export**. Both barrels (`os
init`'s `src/objects/index.ts` and the line `os generate` appends for
all seven generators) re-export `default`, so no barrel spelling moves
and no user barrel needs touching.
- The authored OWD comment block is unchanged byte for byte in all three
emitters (only the closing `};` became `});`);
`init-template-comments-self-contained.test.ts` is green.
- `generate.ts`'s docblock states that the init/generate parity now
covers the declaration shape as well as the `sharingModel` value, and
names the pin that holds it.

## Premise check (on `origin/main`, sites located by symbol)

- `TEMPLATES` (both object-bearing entries) and
`GENERATORS.object.generate` emitted the annotated literal: confirmed.
- `create-objectstack`'s bundled `blank/src/objects/note.object.ts` is
already `export const Note = ObjectSchema.create({ … })`: confirmed.
- `scripts/sync-scaffold-emission-policy.mjs` syncs the pnpm/TypeScript
ranges only and reads no declaration shape; `pnpm
check:scaffold-emission-policy` was run (read-only `--check`) and is
green.
- The ruling's `cli.mdx:1323` anchor has drifted with later edits. The
page's only description of the scaffolded object shape was the `os
generate` "What it does" line (it named `Data.ServiceObject`); that line
now describes the factory (and names `defineSkill({ … })` for `skill`,
the one non-object type that is not a typed literal), and the `os init`
section gains a short paragraph naming the shape and the one mechanical
rewrite for older projects.

## Measured: does the objectstack-ai#19720 gate reach a scaffold? Before and after

Built `@objectstack/cli` at the base and at this branch, ran `os init
my-app -t app` and `os init my-plugin -t plugin` (`--no-install`, under
`packages/cli/node_modules` so `@objectstack/spec` is found by the
upward walk), then `os g object my_app_order_line` in each, then the
project's own gates. The repo gate was driven through its exported
`sweep()` over a tree holding the four scaffolded object files (plus the
driver file it reads its text family from).

| reading | before (base `3bd28e2b`) | after (this branch) |
|:--|:--|:--|
| `os validate` / `os compile` / `tsc --noEmit`, init only | exit 0 / 0
/ 0 (both templates) | exit 0 / 0 / 0 |
| same, after `os g object` | exit 0 / 0 / 0 | exit 0 / 0 / 0 |
| `check-keyed-text-bounds` `sweep()` over the 4 scaffolded files | 0
objects parsed, **4 shape violations** (`… is declared as a plain object
literal — use ObjectSchema.create`) | **4 objects parsed, 0 shape
violations**, 0 refusals |
| compiled `dist/objectstack.json` | sha256 `3981f1ab…` (app),
`e6d2c61d…` (plugin) | **byte-identical** (`cmp` equal) |

So the platform's own shape gate refused every scaffold before this
change, but only as a repo script: a user project carries no `scripts/`,
and `os validate` / `os compile` never judged the shape. After it, the
gate parses all four. The compiled artifact is byte-identical, which is
the measured basis for `Clause-②: no` (no published payload changes).

## Pins

- **New**: `packages/cli/test/scaffold-object-declaration-shape.test.ts`
reads every emitter's bytes with the TypeScript parser (roster derived
from `TEMPLATES` and `GENERATOR_SCAFFOLD_TARGETS`) and asserts: value
import of `ObjectSchema` from `@objectstack/spec/data`; exactly one
top-level declaration, initialised by `ObjectSchema.create({…})`, with
no annotation; the default export is that binding; and **one signature
across `os init` and `os generate object`**, which is the parity the
docblock claims. Two controls prove the reader can refuse each half (the
pre-ruling annotated literal; a type-only factory import).
- **Repointed** (they asserted the refused spelling, per the
`domain:services` pointer `5788276757`):
`generate-emission-parses.test.ts` (`:148` and the `class`
discriminator, which asserted `const class:`),
`generate-refuses-unparseable-name.test.ts:255`, and the worked examples
in `emitted-source-parses.ts`, `generate-emission-parses.test.ts` and
the `generate.ts` refusal comment. Docblock-only:
`scaffold-emission-typechecks.test.ts` (why the pin still stands after
the annotation is gone) and
`generate-refuses-name-outside-charset.test.ts` (`const class:` → `const
class =`).
- Unchanged and still covering it:
`scaffold-emission-typechecks.test.ts` (tsc over every emitted
scaffold), `generate-scaffold-validates.test.ts` and
`init-scaffold-authoring-rules.test.ts` (runtime loads, which now
execute the factory), `init.test.ts` (its assertions are name and
barrel, not shape).

### Ablation (the new pin can fail)

Committed first, then `node scripts/ablation-replace.mjs` swapped the
`os generate object` emitter's `import { ObjectSchema }` for `import
type { ObjectSchema }` and ran the pin: **2 failed / 5 passed**. The
failures were `'os generate object order_line'` (`is not value-imported
… (type-only)`) and `one signature across every door` (the generate
door's signature diverged). Restore proven by the tool: blob
`03b8006959dc` == HEAD and `git diff HEAD` empty. The direction observed
was red, as expected.

## Verification (head `3082b024`, after merging `origin/main`
`836aad2a`; round 1 at `468000c4` below)

`main` moved under this branch with PR objectstack-ai#20164 (same package), so the
suite was re-run after the merge:

- `@objectstack/cli` unit tier, `vitest run --project unit
--maxWorkers=2 --shard=N/4` × 4: **226 files / 3199 tests passed** (949
+ 780 + 723 + 747).
- `@objectstack/cli` integration tier, run locally because the diff
touches two integration-tier files: `generate-refuses-unparseable-name`
+ `generate-refuses-name-outside-charset`, **2 files / 25 tests
passed**. The rest of the integration tier is declared to CI.
- `pnpm --filter @objectstack/cli typecheck` (tsc +
`check:test-typecheck`): exit 0; the new test file is in the test
program (`tsc -p tsconfig.test.json --listFilesOnly` counts it).
- `pnpm lint` (full, `eslint . --no-inline-config`): exit 0.
- Gates derived by `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (94): all 94 exit 0; `--ran`
verdict: `94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED`.
- Before the merge (head `20526f3d`): unit tier 225 files / 3163 tests
passed, the same two integration files 25/25, typecheck exit 0.
- Round 1 (head `468000c4`: `origin/main` `d7c02413` merged as
`e5499d52`, then the one-sentence `cli.mdx` correction naming
`defineSkill` for `skill`): the 41 docs-scoped gates (`dispatch-gates
--commands content/docs/deployment/cli.mdx`) all exit 0, `--ran` 41 of
41 accounted for, 0 NOT-MEASURED; `pnpm lint` exit 0; `node
scripts/check-issue-citations.mjs` answered `no issue citations added
against d7c0241 (3 file(s) read)`. The cli test tiers were not re-run
locally on this head; CI runs them.

## Acceptance notes

- `scripts/check-keyed-text-bounds.mjs`'s refusal text says 「the `os
init` shape imports only `* as Data`」. After this change that describes
the shape older `os init` releases emitted, not the current one; it is
still the right advice for a converted file. `scripts/**` is read-only
for this lane. Carrier: the spec lane when it next touches that gate
(for example when objectstack-ai#17418 is unblocked). Noted, not filed.
- Reported to the seat, not addressed here: in an `os init` project, `os
g object order_line` writes `name: 'order_line'`, and the project's own
`os validate` then refuses it (`Object 'order_line' is missing the
package namespace prefix`). Measured at the base; this PR does not
change it.
- Local tooling observation: `pnpm check:type-check-debt`
(`--re-measure`) runs a whole-workspace `turbo run build` before tsc. A
local timeout that kills it mid-build leaves some packages' `dist/`
without declarations, and `check:dual-build-cjs-loads` then flags them.
Rebuilding the two packages cleared it; CI builds fresh.

---

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

---------

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

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