Skip to content

feat(spec)!: manifest.id enforces the reverse-domain rule its registry face already had - #18319

Draft
os-warren wants to merge 14 commits into
mainfrom
claude/issue-17534-manifest-id-reverse-domain-regex
Draft

os-warren wants to merge 14 commits into
mainfrom
claude/issue-17534-manifest-id-reverse-domain-regex

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #17534 — this lands the ruled change on the authoring contract, the refusal, both scaffolders and the ADR-0087 entry. The half it deliberately leaves open is named below under The one blocker, and needs the seat's word before it can be written; #17534 remains open until that half lands.

What the ruling asked for, and what is here

1. One declaration, referenced from both sites. MANIFEST_ID_PATTERN is exported from packages/spec/src/kernel/manifest.zod.ts; ManifestSchema.id and PackageSchema.manifestId both reference it. manifest.test.ts holds a per-field verdict table across the two, so a future edit to either has to break a test to drift. The .describe() moves to "must match reverse-domain notation" in the same commit as the regex — #17921's self-contained half, the wording constraint, is satisfied by construction.

2. Refusal with a remedy (#4001). The message names the key, echoes the value, shows both documented examples, and prescribes a repair it has already checked against the pattern. Built on the existing formatSuggestion from shared/suggestions.zod.ts — no new message builder.

manifest.id: Invalid package id 'blank' on `manifest.id`. Expected reverse-domain
notation ('com.steedos.crm', 'org.apache.superset') — lowercase dot-separated
segments; hyphens allowed inside a segment, underscores are not.
Did you mean 'com.example.blank'?

The suggestion arm is conditional on purpose: a bare word gets com.example.NAME; a value that already carries a dot gets its underscores hyphenated in place; a value nothing mechanical rescues gets no suggestion rather than a wrong one. Every candidate is tested against the pattern before it is offered — #17461's lesson applied to the remedy, not only to the examples.

The two @examples were verified first, as the ruling required. com.steedos.crm and org.apache.superset both match. They are no longer a comment: MANIFEST_ID_EXAMPLES is the one list the TSDoc, the refusal and the pin all read, so an example that fails its own rule is now a red test rather than bad advice.

3. Both scaffolders derive a conforming id. The bundled template ships com.example.blank, and create-objectstack interpolates deriveManifestId(projectName). os init derives from the project name too. The second half prerequisite 4 named is real and is fixed here: os init my-app sanitizes the namespace to my_app and every template interpolated that namespace into the id, producing com.example.my_app — refused by this very schema. One project name, two identifiers, contradictory rules; neither can be read off the other. Pinned from both sides against the imported MANIFEST_ID_PATTERN, never a restated regex.

4. Migration. ADR-0087 semantic entry manifest-id-reverse-domain-required — a structured TODO, no automatic rename, because the value IS the identity: the registry addresses a package by manifest_id, an installed row is keyed on it and a dependent declares it. The changeset carries the matching disposition marker and ships minor under the launch-window convention. Sub-question (1) is answered in the entry's own reason from the TSDoc's words: the rule is "unique across the entire ecosystem", so a package that is never published is still named for the ecosystem it may join.

The one blocker — measured, not assumed

The ruling states "the one non-conforming id in the tree is the scaffold itself". That is contradicted by measurement.

First, the question nobody had measured — does manifest.id reach ManifestSchema at authoring time, or only on the publish path? A failing probe, with a lit control on an existing ManifestSchema constraint:

A control  version=not-semver        REFUSED -> manifest.version: Invalid string: must match pattern
B qa id    com.dogfood.flow_fixture  ACCEPTED     (before this change)
C scaffold blank                     ACCEPTED     (before this change)

The control proves the instrument: manifest really is parsed through ManifestSchema inside defineStack(). So the answer is yes, they parse — and with the regex in place, B and C refuse.

Second, the population. Every manifest: { id: ... } literal in the tree, run through the built ManifestSchema: 21 files under packages/qa are refused, each for the same reason — an underscore in the last segment, which manifest.namespace admits and an id segment does not.

packages/qa/dogfood/test/fixtures/analytics-admission-fixture.ts:60      com.dogfood.analytics_admission
packages/qa/dogfood/test/fixtures/attachments-fixture.ts:134            com.dogfood.attachments_fixture
packages/qa/dogfood/test/fixtures/cbp-fixture.ts:48                     com.dogfood.cbp_fixture
packages/qa/dogfood/test/fixtures/comments-fixture.ts:133               com.dogfood.comments_fixture
packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts:54  com.dogfood.email_template_fixture
packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts:88         com.dogfood.endpoint_policy_fixture
packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts:96    com.dogfood.durable_suspend
packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts:64    com.dogfood.flow_function_effect
packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts:120            com.dogfood.runas_fixture
packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts:74              com.dogfood.flow_fixture
packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts:87              com.dogfood.hookrunas_fixture
packages/qa/dogfood/test/fixtures/label-scope-fixture.ts:50             com.dogfood.label_scope
packages/qa/dogfood/test/fixtures/override-composite-fixture.ts:84      com.dogfood.override_composite
packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts:52               com.dogfood.rls_fixture
packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts:50 com.dogfood.webhook_fixture
packages/qa/dogfood/test/hook-error-format.dogfood.test.ts:50           com.dogfood.hook_error_format
packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts:53   com.dogfood.user_facing_marking
packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts:73        com.dogfood.registry_gate
packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts:91    com.dogfood.sys_file_write_refusal
packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts:42   com.dogfood.validation_message_locale
packages/qa/downstream-contract/src/stack.ts:14                         com.objectstack.downstream_contract

Each fixture calls defineStack at module top level, so the refusal is an import-time throw: seven of them were confirmed that way directly, the rest through the built schema per literal (the remaining fixtures import @objectstack/plugin-security, whose dist this measurement did not build — that is NOT MEASURED at module level, and is why the per-literal instrument is the one quoted).

They are not renamed here, and the regex is not weakened to admit underscores. packages/qa is a domain this card did not declare, and twenty fixture ids is a different PR from the one that was ruled. So this PR is red on the dogfood gate until the seat rules on the rename. Everything else is green; the gap is exactly these 21 files.

What makes the rename cheap, if it is authorized: each id occurs exactly once in the tree — its own declaration. git grep -F over all twenty-one returns one hit each, so there is no installed row, no dependencies entry and no snapshot pinning any of them. The transform is _ to - in the last segment, and the refusal already prints the target value for each one.

Two apparent counterexamples outside packages/qa are not manifest ids, and nothing needs doing about them: packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts:62 is a flow NODE id, and packages/objectql/src/plugin.integration.test.ts:259 is a plain object handed to ctx.registerService, never parsed by this schema.

In-package fixtures repaired here

The same defect existed inside the packages this card does own, and those are fixed — mechanical, single-literal, and each fixture keeps pinning exactly what it pinned before: three bare ids in packages/spec tests (p, acme), four in packages/cli tests (todo, demo, bad, a/b), and the five golden corpus entries in packages/cli/src/lint/corpus.ts. That last one is not a fixture: it is the worked-example set the metadata generator imitates, and it was teaching bare-word ids.

packages/create-objectstack/vitest.config.ts gains an anchored source alias for the one spec subpath its tests now import, because pnpm check:test-source-alias correctly refuses a pin whose verdict is a function of build state.

Not touched, deliberately

docs/adr/0087-metadata-protocol-upgrade-contract.md — the registration this gate wants is the changeset's disposition marker plus the entry file, both present. Editing the ADR would make this a governed-surface diff for no gain. Also untouched: every file in open PR #18198, packages/spec/src/shared/retired-key.ts, packages/spec/src/ui/chart.zod.ts.

Verification

Run at d0d859edbc, the final commit.

what result
node scripts/pm/dispatch-gates.mjs --ran 127 derived families, 127 run, 0 NOT-MEASURED, 0 UNRUN, every one recorded with its exit code, all zero
pnpm --filter @objectstack/spec test 480 files, 13715 passed, 1 skipped
pnpm --filter @objectstack/spec typecheck pass
pnpm --filter @objectstack/cli exec vitest run --project unit 209 files, 2988 passed
pnpm --filter @objectstack/cli typecheck pass
pnpm --filter create-objectstack test 16 files, 215 passed
pnpm --filter create-objectstack typecheck pass
pnpm lint (repo-wide, eslint . --no-inline-config) exit 0 — the full population, not a narrowing
pnpm --filter @objectstack/spec check:generated all 15 artifacts current after regenerating api-surface, export-origins, content/docs/references
packages/qa dogfood tier NOT RUN — known red, the 21 files above

packages/cli's integration tier is declared to CI: the diff touches no spawn entry, no bin/, no test/helpers/serve-process.ts and no driver or kernel boot path.


Generated by Claude Code

@github-actions github-actions Bot added size/l dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 15, 2026
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, create-objectstack, objectstack-blank, @objectstack/plugin-dev, @objectstack/downstream-contract, @objectstack/spec, touching 25 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/plugins/plugin-dev/src/index.ts, packages/spec/api-surface/kernel.json, packages/spec/export-origins/kernel.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

16 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 37af6533be1af36671e52eefebfd7740e24d56f1.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/plugins/plugin-dev/src/index.ts, packages/spec/api-surface/kernel.json, packages/spec/export-origins/kernel.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 144 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 37af6533be1af36671e52eefebfd7740e24d56f1packageMentionDocs.

Which tree this was computed on

This run read content/docs from 26edca599cd8fbf7aef75834ab340d784c31f4db — the merge of head 4f277510f35680bf85738999f891e03cbf6a5e8a into base 37af6533be1af36671e52eefebfd7740e24d56f1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 26edca599cd8fbf7aef75834ab340d784c31f4db && git checkout 26edca599cd8fbf7aef75834ab340d784c31f4db
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 37af6533be1af36671e52eefebfd7740e24d56f1 4f277510f35680bf85738999f891e03cbf6a5e8a && git checkout -B drift-repro 37af6533be1af36671e52eefebfd7740e24d56f1 && git merge --no-ff 4f277510f35680bf85738999f891e03cbf6a5e8a

node scripts/docs-audit/affected-docs.mjs --json 37af6533be1af36671e52eefebfd7740e24d56f1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 37af6533be1af36671e52eefebfd7740e24d56f1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-warren os-warren added the domain:spec label Sep 15, 2026 — with Claude

Copy link
Copy Markdown
Collaborator Author

This PR is red on purpose, and it is blocked on one maintainer decision — not on an unhandled defect

What is failing, read from GET /commits/d0d859edbcfcf85240665f13207670f304ed5311/check-runs at 2026-09-15T17:16:22Z: 43 check runs, 26 success, 6 skipped, 11 failingTest Core and its six shards, Dogfood Regression Gate and its three shards.

Why. The card's ruling adopts PackageSchema.manifestId's regex /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/ for ManifestSchema.id, on the stated premise that 「the one non-conforming id in the tree is the scaffold itself」. That premise is false, and the implementer measured it rather than working around it. Run through the BUILT ManifestSchema at this head, 26 in-tree manifest id literals are refused: 21 real (all under packages/qa, every one failing for an underscore in the last segment — com.dogfood.flow_fixture, com.objectstack.downstream_contract and their siblings), 3 are os init template strings now conforming by construction, and 2 are false positives (a flow node id and a registerService mock).

The reachability question that decides whether those 21 matter was answered by a failing probe with a lit control, before the change:

A  control  version=not-semver          → REFUSED (must match pattern)
B  qa id    com.dogfood.flow_fixture    → ACCEPTED
C  scaffold blank                       → ACCEPTED

So manifest.id does reach ManifestSchema at defineStack() time, and the 21 are real refusals rather than a theoretical concern.

Why nothing was done about them. The dispatch order for this card was explicit: if the 21 turned out to be real, stop and report — ⛔ do not rewrite twenty-one fixture ids on an implementer's own authority, and ⛔ do not weaken the regex. Both prohibitions still hold. Weakening is additionally refused by the ruling itself, which requires the pattern 「declared once and referenced from both sites so the two cannot drift again」; admitting underscores on one side re-opens exactly the drift the card exists to close. packages/qa is also a domain this card did not declare in its claim.

What is needed. One decision, and it is the maintainer's because the ruling's own population statement is what turned out to be wrong:

  • A — authorize the 21 renames inside this PR: one mechanical edit per file, underscore to hyphen in the last segment. Measured cost: each id occurs exactly once in the whole tree, at its own declaration, so nothing else addresses the old value, and the refusal message already prints the target. The PR grows by 21 single-line edits into an undeclared domain.
  • B — split: a sibling card renames the 21 first, this PR lands after. Cost: two PRs, an ordering dependency no gate enforces, and this PR sits red until then.

The decision is recorded on the card at #17534 and is with the maintainer now. This PR stays draft with no auto-merge and no enqueue until it is answered; the rest of the lane's queue keeps moving.

Not blocked, and already fixed in this PR — a second premise that turned out to be real: every os init template interpolated the snake_case namespace into the id, so os init my-app wrote com.example.my_app, which this schema refuses. Landing the regex without that fix would have shipped a scaffolder whose own output fails its own first os validate.


Generated by Claude Code

…omain manifest ids

`manifest.id` now enforces the reverse-domain rule, and 21 fixture
declarations under `packages/qa` carried an underscore in their last
segment. 20 of them are renamed here, underscore to hyphen, exactly the
repair the refusal message itself suggests.

The 21st, `com.dogfood.flow_fixture` in
`packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts`, is left alone:
it is not a single-occurrence declaration. The same literal appears twice
more in `packages/spec/src/kernel/manifest.test.ts`, as the sample input
for the "repairs a dotted value in place" refusal-message assertions. The
rename mandate covers single-occurrence declarations, so that one is
reported rather than swept. `test/flow-node.dogfood.test.ts` therefore
still refuses at boot.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
`com.dogfood.flow_fixture` was held back in the previous commit because
the literal also appears twice in `packages/spec/src/kernel/manifest.test.ts`,
so it was not a single-occurrence declaration and the mechanical rename
mandate did not obviously cover it.

Those two hits are deliberate NEGATIVE TEST INPUT, not references to this
fixture: line 643 asserts the refusal for that value suggests
`com.dogfood.flow-fixture`, and line 653 feeds it alongside `blank`,
`my_app` and `support_desk` as a batch of ids the schema must refuse.
They are left exactly as they are — renaming them would delete the test
of the rule this card adds.

The new id is the one the refusal itself prescribes for this value, the
same underscore-to-hyphen repair the other 20 took. `manifest.test.ts`
stays green (66 passed) with the fixture renamed, which is what shows the
two are independent.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>

Copy link
Copy Markdown
Collaborator Author

Status refresh — the blocker changed, and my earlier comment on this PR is now the wrong description of it

My comment 5684753340 said this PR was held on one question: whether to rename 21 packages/qa ids. The maintainer ruled A (rename them here), the implementer did, and f983362592 completed the twenty-first. Test Core stayed red anyway, so the population was re-measured — and 21 was not it either.

Measured at head f983362592, by two lexically unambiguous passes (the id inside manifest: { on the same line, or on the line immediately after, so nested id keys on navigation items, state machines, view probes and migration entries cannot contaminate the count): 67 manifest ids are refused by the ruled regex, across eight packagespackages/cli 45, packages/runtime 8, packages/plugins 6, packages/spec 3, packages/qa 2, packages/objectql 1, packages/lint 1, packages/rest 1. Both numbers are floors: an id written across three or more lines is invisible to both passes.

The fact that decides how serious this is: of the 63 from the first pass, 62 are in a .test.ts file or under a /test/ path, and the single exception is a docblock example (packages/plugins/plugin-dev/src/index.ts — prose in a comment, not executed). So no shipped, executable manifest id in this repository is refused. The contract change is sound; what was under-estimated is the fixture churn it costs.

Current CI, head f983362592, run 35036842827 still in_progress at 2026-09-15T23:54:42Z: 37 runs — 26 success, 3 skipped, 3 failing (Test Core 1/6, 2/6, 4/6), 5 still going. Down from 11 failing two heads ago; the remainder are the un-renamed fixtures above.

Why nothing further is being pushed. Renaming ~46 more fixtures across seven additional packages is a scope decision, not a repair — and an hour ago I wrongly told the implementer to fold two packages/cli fixtures in as "the same class its predecessor already handled". With eight packages in play that framing does not hold, and I have retracted it. ⛔ No further renames on seat authority. The implementer has been stopped and is reporting needs_decision.

The full measurement and the question now with the maintainer are on the card: #17534 (comment)

This PR stays draft, with no auto-merge and no enqueue, until that is answered. The 21 ruled renames stay as they are — they are correct and they are landed on this branch.


Generated by Claude Code

…pellings

The maintainer ruled that the fixtures outside `packages/qa` are renamed in
this PR too. Each new id is the one the schema's own refusal prescribes for
the old value, so the tree and the diagnostic agree.

Renamed (18 ids, 16 files): `packages/cli` 14, `packages/lint` 3,
`packages/metadata` 1, `packages/plugins/plugin-dev` 3 (see below), plus the
docblock noted last.

Three of these were NOT single-literal swaps, and are called out because a
naive rename would have silently deleted what the test pins:

  * `dev-i18n-packages-reader.test.ts` builds a dependency CYCLE between two
    packages, and the `dependencies` keys address the ids BY NAME. The ids and
    both dependency keys are renamed together; renaming only the ids would
    have dissolved the cycle and left the test asserting nothing. It still
    reports `Circular dependency detected`.
  * `metadata-type-schema-gate.test.ts` and `format-zod-union.test.ts` and
    `authoring-rule-command-parity.test.ts` each carry a `namespace` that
    repeats the old id, and the first also carries a route path
    `/api/v1/apps/gate_probe/things`. Only `manifest.id` is renamed —
    `namespace` has its own rule and the path pairs with it.
  * `plugin-dev/src/index.ts` is a DOCBLOCK example, not a fixture: prose
    teaching `manifest: { id: 'my-app' }`, an id its own schema now refuses.
    It cannot red a test; it is corrected because it is published teaching.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
…ges/qa

19 more ids, each the target the schema's own refusal prescribes for the old
value, rewritten at the exact offset of the `manifest.id` literal so that a
`namespace`, a `name` or a route path repeating the old spelling is left alone.

Scope of THIS commit is deliberately the single-occurrence ones. Every id here
occurs exactly once in its own file, so the rename cannot silently unhook an
assertion that addresses the old value.

Not included, and reported rather than guessed:

  * 15 ids whose literal occurs MORE than once in its own file, so renaming
    the declaration alone would leave assertions naming the old value —
    `pkg-a` (7 occurrences), `test` (6), `a` (6), `my-app` (5), `demo` (3) and
    ten more. These need a coordinated rename of the id and every reference,
    which is reading work per file, not a mechanical swap.
  * `com.test.14397` — the schema offers NO suggestion for it, because the
    failing segment starts with a digit and no mechanical repair exists.
  * `artifact-granted-permissions.test.ts` — not a rename at all: it pins the
    ADR-0130 D4 door ORDER, that `''` passes the schema and is caught one door
    later. The new rule moves the refusal to door 1, so no conforming id can
    preserve what the test pins.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
The last 16, each read before it was touched. Every replacement is anchored on
the `id:` key, so a `name`, a `namespace` or a field named the same is left
alone, and each file's occurrence count was asserted before and after.

Reading first is what made these safe, and two would have gone wrong without it:

  * `artifact-collections.test.ts` has `'a'` six times, but only two are the
    package id — the other four are a FIELD named `a`
    (`{ a: { name: 'a', type: 'text' } }`). A whole-file swap would have
    renamed a field the assertions address.
  * `package-registry.test.ts` and `lint-protocol-range.test.ts` repeat the old
    id as a `namespace`, and `serve-host-config.test.ts`,
    `metadata-collection.test.ts` and others repeat it as a free-form `name`.
    `namespace` has its own rule and `name` has none; neither is renamed.

Where the repeat WAS a real reference it moved with the id: all seven `pkg-a`
in `packages-capability-gate.test.ts` (package record ids and manifest ids in
the same mock), both in `packages-uninstall-envelope.test.ts`, both `test-app`
registrations in objectql, and both `locale-producer-app` sites in runtime.

`com.test.14397` becomes `com.test.card-14397`: the schema offers no suggestion
for it, because the last segment starts with a digit, so the name was ruled
rather than derived. `ManifestSchema` confirms the replacement parses.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
…tree

The os-regen merge sequence's collection commit. `api-surface/kernel.json` and
`export-origins/kernel.json` were edited on BOTH sides, so the merge driver ran,
exited 0 and silently kept one side; step 2 took main's side and this commit
carries the regeneration from the merged tree, built first so api-surface reads
a dist that matches src rather than reporting phantom removals.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
…geset names the fail-OPEN reversal

`ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, and
`AssembledPackageBodySchema` extends `ManifestSchema`, so the artifact package
entry schema carries it too. Three pins in the `#13457` door block described a
world where `''` survived that schema:

  - `''` was refused by `artifactPackageId` one door later (DOOR 2);
  - `{ id: '', name: 'x' }` was refused by NEITHER door and carried as `x`
    through the `id || name` fallback;
  - a consent record keyed by `''` bound to nothing and the package still
    loaded with no consent record — fail-OPEN.

All three are refused at DOOR 1 now. The pins are rewritten to the measured
behaviour rather than loosened: each still names ITS OWN door by message and
asserts the absence of the other door's, so none of them would survive deleting
a whole door — the property the block's header exists to protect.

The last of the three reverses direction, so the changeset says so in those
words: fail-OPEN to fail-CLOSED on a consent/permission path, and the affected
population is artifacts carrying `manifest.id: ''`, which the registry face has
always refused at publish and whose granted-permissions consent already silently
did not apply.

The refusal is the artifact PACKAGE door refusing a malformed id, ⛔ not the
permission seam acquiring teeth: #17147's repo-wide "registered, not enforced"
pin is untouched and still green. #17148 (whether an unbindable consent record
should refuse the artifact) is NOT settled by this and is noted as still open in
the case that used to imply it.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <[email protected]>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 16, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…constants are exported (objectstack-ai#18462)

Part of objectstack-ai#18169. Docs-only: one false sentence in published CHANGELOG
text, amended in place. No source file is touched.

## The AGENTS.md rule this PR obeys

Quoted from `AGENTS.md`, *Documentation Guardrails*, the
`packages/*/CHANGELOG.md` row (read 2026-09-16T12:22Z at `e4449b4ef`):

> `packages/*/CHANGELOG.md` | **RELEASE-OWNED** | ❌ Never edit in a code
PR — `changeset version` compiles it from `.changeset/*.md` and it ships
inside the npm tarball as the text an upgrading agent greps (§
*Post-Task Checklist* step 3). Your PR's input is its **changeset**, on
a hard, unwatched deadline: the release that consumes it deletes that
input and publishes the sentence. Factual error in a released entry →
**amend that entry in a dedicated docs-only PR**, ⛔ never an erratum in
a later entry and never a rider on code changes — the reader greps the
tombstoned symbol and lands on the old entry, so a correction anywhere
else is one it never reaches; what shipped stays recorded in the
published tarballs and in git history.

That is why the amendment is in place, at the entry the reader's grep
lands on, and why this PR carries no source change.

## Old sentence, new sentence

The entry is `## 17.3.0` → `- 1af8286:` (the objectstack-ai#13935 fix),
`packages/lint/CHANGELOG.md:2176`. Line number derived here, not taken
from the card.

**Before:**

```
  `FIELD_RULE_AMBIENT_ROOTS` and `FIELD_RULE_JUDGED_ROOTS` are exported beside
  the existing `FIELD_RULE_BOUND_ROOTS`.
```

**After:** a paragraph that states only what the export statement
actually names — see the diff. Both symbol names are kept in the text on
purpose: the rule's stated rationale is that the reader greps the symbol
and lands on this entry, so removing the names would move the landing
spot.

## The reading behind the new sentence

Every number below was taken in this worktree, by this seat.

### Leg 1 — the export statement (the source of truth)

`packages/lint/src/index.ts:53`, the package's only export site for this
family:

```
export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js';
```

Occurrences in `packages/lint/src/index.ts`, read
**2026-09-16T12:41:03Z** at branch tip `8f8a47ce1` (base `e4449b4ef` =
`origin/main`):

| name | occurrences | note |
|:--|--:|:--|
| `FIELD_RULE_BOUND_ROOTS` — **lit control** | **1** | inside the export
statement above |
| `FIELD_RULE_NOWHERE_BOUND_ROOTS` — **dark control** | **0** | a real
`export const` of `validate-expressions.ts` (`:734`), the very module
line 53 imports from — so a 0 here reads "not re-exported", not "grep is
broken" |
| `FIELD_RULE_AMBIENT_ROOTS` | **0** | |
| `FIELD_RULE_JUDGED_ROOTS` | **0** | exported from
`validate-expressions.ts:746`, never re-exported from the entry |
| `export *` (a second path to a consumer) | **0** | |

The package declares a second published subpath, `./runtime`. Probed
too, same reading time: `packages/lint/src/runtime.ts` contains **0**
occurrences of `FIELD_RULE` and **0** star-exports. `exports` names
exactly `.` and `./runtime` and no wildcard, so those two are the whole
consumer-reachable surface.

### Leg 2 — the freshly built `dist` (this branch)

Built here with `pnpm --workspace-concurrency=2 --filter
'@objectstack/lint...' build` (exit 0), then read
**2026-09-16T12:40:43Z** at `8f8a47ce1`:

| name | `dist/index.d.ts` | `dist/index.js` | in the export clause /
export block? |
|:--|--:|--:|:--|
| `FIELD_RULE_BOUND_ROOTS` (lit control) | 3 | 3 | **yes**, both |
| `FIELD_RULE_AMBIENT_ROOTS` | 0 | 0 | no |
| `FIELD_RULE_JUDGED_ROOTS` | 2 | 2 | **no** — both `.d.ts` hits are
`{@link}` docblock references |
| `FIELD_RULE_NOWHERE_BOUND_ROOTS` | 1 | 4 | no |

⚠️ Note the shape: a raw occurrence count on a bundled `.d.ts` is
**not** the right probe. `FIELD_RULE_JUDGED_ROOTS` is present in the
emitted declaration file and still not exported. The probe that decides
is membership of the export clause, which is what the table's last
column reads.

### Leg 3 — the published tarball (what a consumer actually installs)

`npm view @objectstack/lint version` → `17.4.0`;
`https://registry.npmjs.org/@objectstack/lint/-/lint-17.4.0.tgz`
downloaded and unpacked, read **2026-09-16T12:25:43Z**:

- `package/CHANGELOG.md:2176` carries the false sentence verbatim. The
defect is published, not merely in-tree.
- `package/dist/index.js`: the ESM export block begins at line 14522;
the only member of this family inside it is `FIELD_RULE_BOUND_ROOTS`
(line 14585). Every `FIELD_RULE_AMBIENT_ROOTS` /
`FIELD_RULE_JUDGED_ROOTS` occurrence sits at lines 1163–1253, i.e. in
the bundle body, above the export block.
- `package/dist/index.d.ts`: same — the export clause names
`FIELD_RULE_BOUND_ROOTS` and neither of the other two.

⇒ `import { FIELD_RULE_AMBIENT_ROOTS } from '@objectstack/lint'` fails
to resolve against the currently published package. Acted on, the
sentence fails.

## `files[]` readings, and the changeset argument

Packages the diff touches, read 2026-09-16T12:41:03Z at `8f8a47ce1`:

| package | path in this diff | `private` | `files[]` | does this diff
move published bytes? |
|:--|:--|:--|:--|:--|
| `@objectstack/lint` 17.4.0 | `packages/lint/CHANGELOG.md` | `false` |
`["dist","README.md","CHANGELOG.md"]` | **yes** — `CHANGELOG.md` is
whitelisted, and the published 17.4.0 tarball above contains it |
| `@objectstack/spec-monorepo` 4.0.1 (repo root) |
`.changeset/lint-changelog-export-claim.md` | `true` | none | no —
private, and `.changeset/` is release input, not shipped |

**So `skip-changeset` is unavailable by its own definition.** AGENTS.md,
*Post-Task Checklist* step 3: "A bug fix in a released package takes a
**`patch`** changeset — never none, and ⛔ never `skip-changeset`: that
label is for a diff that publishes nothing from any released package."
`@objectstack/lint` is a released package (17.4.0 is on the registry,
read above) and this diff moves bytes inside its tarball. The usual
docs-only reflex does not reach this file. ⇒ **a `patch` changeset is
added.**

**The circularity, stated rather than ignored.** A changeset's own
release appends a new entry to the top of the very file being corrected.
Three readings settle it:

1. **The amendment survives that release.** `changeset version`
*prepends* under the package heading — read off this file's own
structure: `## 17.4.0` at line 3, `## 17.3.0` at 781, `## 17.2.0` at
3075, descending. Older entries are not rewritten, so the corrected
17.3.0 paragraph is durable.
2. **The new entry is not the correction, and is not an erratum in the
sense the rule forbids.** AGENTS.md forbids correcting *by* an erratum
in a later entry, because "the reader greps the tombstoned symbol and
lands on the old entry". Here the reader lands on the amended paragraph
— the correction is where the grep goes. The release note records that
the amendment happened; it is not asked to carry it.
3. **Without a changeset the correction never ships.** Published
tarballs are immutable; the corrected text becomes published text only
on this package's next publish. With no changeset, that publish happens
whenever some unrelated change happens to release `@objectstack/lint` —
i.e. the fix to a *published-text* defect would depend on an unrelated
event. The changeset is what makes the repair reach the surface the card
is about.

If a reviewer prefers the opposite call, the lever is one file
(`.changeset/lint-changelog-export-claim.md`) plus the `skip-changeset`
label; nothing else in this PR moves.

## What was deliberately **not** done

⛔ **The two names were not exported.** The card names two products and
refuses to choose between them; the dispatching seat chose, narrowly,
and only "correct the false record" was dispatched. Actually exporting
`FIELD_RULE_AMBIENT_ROOTS` / `FIELD_RULE_JUDGED_ROOTS` would **widen the
published surface** — the manual floor. It would carry `Clause-②: yes`,
take at least a `minor`, and is the maintainer's call, not an
implementer's. It is a separate card if anyone wants it, and the card's
own acceptance ③ forbids doing both in one PR.

This seat has no dissent to record: the amendment is the product that
restores truth to text already shipped, and it is complete on its own.

## Scope

`packages/lint/CHANGELOG.md` plus the changeset argued for above. No
source file, no test, no `content/docs/releases/**`. In particular
`packages/lint/src/validate-expressions.ts` and its test are untouched —
open PR objectstack-ai#18319 is editing `validate-expressions.test.ts` and that work
is not this PR's.

## Gates

Derived, not recalled: `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` against the real change set (2
paths), which named **52** commands. All 52 were run; every exit code is
in the report handed to the dispatching seat.

- **50 of 52 exit 0.** 31 were green on the first pass; 16 more after
`pnpm install` (a fresh worktree has no `node_modules`, so they had
exited 3 — PREREQUISITE NOT MET); 3 more after the
`@objectstack/lint...` build above.
- **2 remain NOT MEASURED, exit 3**: `check:dual-build-cjs-loads` and
`check:lean-entry-closure`. Both need a whole-repo `dist/` and say so in
their own words ("this gate reads built output, and some package has no
dist/"; "this gate loads BUILT entry points"). A repo-wide build is CI's
run, not this PR's, and this diff contains no source byte, so neither
can be moved by it. Exit 3 is neither a pass nor a finding.
- `check:pm-dispatch-gates` is **not** among the derived families for
these paths and was not run.

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

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation domain:spec needs:contract-review size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants