Skip to content

chore: version packages - #10

Merged
huangyiirene merged 1 commit into
mainfrom
changeset-release/main
Jan 18, 2026
Merged

huangyiirene merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

@objectstack/[email protected]

Patch Changes

  • b58a0ef: Initial release of ObjectStack Protocol & Specification.

@huangyiirene
huangyiirene merged commit 95a535a into main Jan 18, 2026
xuyushun441-sys added a commit that referenced this pull request Jun 1, 2026
…ility coverage (#1478)

Add Prime Directive #10: when you encounter a bug/gap/unenforced capability
that's unrelated to the task or too large to fix in scope, open a GitHub issue
and link it — don't silently expand scope or leave it buried. Corollary: never
advertise or demo a capability the runtime doesn't deliver (declared ≠
enforced). Cites the validation-rule enforcement gap (#1475) as the example.

Co-authored-by: Jack Zhuang <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
os-zhuang added a commit that referenced this pull request Jul 16, 2026
…estore PageTabsProps (#2978) (#3012)

* fix(spec): stop gen:schema silently unpublishing transform-bearing schemas (#2978)

PageTabsProps vanished from json-schema/ when #2967 added an
ExpressionInputSchema (.transform) field — zod's toJSONSchema cannot
represent transforms in the default output mode, and build-schemas.ts
silently skipped it, so the next gen:docs run would have deleted the
published PageTabsProps reference section.

Two-part fix:

1. io:'input' fallback — when output-mode conversion fails on a
   transform, retry with io:'input'. These JSON Schemas describe what
   authors WRITE, and the input side of a transform pipe is plain data,
   so it is representable (for PageTabsProps.visibleWhen it emits the
   correct `anyOf: [string, expression envelope]` authoring shape).
   Rescued schemas are marked `x-io: "input"`. This restores
   PageTabsProps and 149 other transform-blocked public contracts
   (ObjectSchema, FieldSchema, FlowSchema, PageSchema, ActionSchema, …);
   only 18 truly unrepresentable schemas (function/Date/BigInt/custom)
   remain skipped.

2. Disappearance ratchet — json-schema/ is gitignored, so the committed
   json-schema.manifest.json records every schema key ever emitted.
   A key present in the manifest but absent from a build now fails
   gen:schema loudly with remediation steps; deliberate retirements must
   remove the key in the same PR. Silent skip remains only for types
   that have never been representable.

Also escape literal `|` in the description cell of generated property
tables (build-docs.ts) — rescued schemas surfaced descriptions with
pipes that split GFM table rows.

Closes #2978

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fn6qMKtJeVbs2KzouWDHhB

* docs(references): regen from restored JSON Schemas (#2978)

gen:docs over the post-fix json-schema/ output. PageTabsProps keeps its
section (now including the visibleWhen items shape from #2967), and the
149 schemas rescued by the io:'input' fallback gain reference sections —
previously delivered-but-undeclared contracts (Prime Directive #10).
Existing table rows with literal pipes in descriptions are re-emitted
with GFM escaping.

Verified: `pnpm docs:build` compiles all regenerated MDX.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fn6qMKtJeVbs2KzouWDHhB

* fix(spec): address CodeQL alerts in gen:schema/gen:docs scripts (#3012)

- build-schemas.ts: read the ratchet manifest directly and treat ENOENT
  as first-run bootstrap instead of existsSync-then-read (TOCTOU).
- build-docs.ts: escape backslashes before pipes in table-cell
  descriptions — an existing `\|` would otherwise decay into an escaped
  backslash followed by a live pipe, splitting the GFM cell.

No output changes: regenerated json-schema/ and references/ are
byte-identical.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fn6qMKtJeVbs2KzouWDHhB

---------

Co-authored-by: Claude <[email protected]>
xuyushun441-sys pushed a commit that referenced this pull request Jul 17, 2026
…rule

Adversarial verification of my own rewrite turned up the irony: "the
runtime validator enforces all 6" is itself advertising a capability the
runtime doesn't fully deliver — exactly what this directive prohibits.

evaluateValidationRules() has two call sites (engine.ts:2265 insert,
engine.ts:2440 single-id update). The options.multi/updateMany branch
never calls it, so on a bulk update all 6 rule types — plus field-level
requiredWhen and per-option visibleWhen authorization, which live inside
the same evaluator — are silent no-ops. The guard warning there is gated
on needsPriorRecord(), which returns false for format/json_schema, so
those two fail with no signal at all.

#1475's 9-vs-3 gap is genuinely closed; the switch does handle all 6.
But "declared == enforced" was true of the switch, not of the system.
Reworded to say what's true, and filed #3106 for the call-site hole
rather than expanding this docs PR into an engine fix (PD #10).

The example now carries the transferable lesson: a case label is not
enforcement — check the call site.

Co-Authored-By: Claude Fable 5 <[email protected]>
os-zhuang added a commit that referenced this pull request Jul 17, 2026
…rective #10 (#3105)

The example cited "the spec declares 9 validation-rule types but the
write-path validator enforces only 3" as a live gap. #1475 closed that:
the types that could never be enforced (unique/async/custom) were trimmed
and the rest implemented, so ValidationRuleSchema now declares 6 and
rule-validator.ts handles all 6.

Rewritten as a closed loop, which can't drift the way the old claim did —
and which demonstrates all three verbs the directive prescribes ("fix it,
trim it, or file an issue") in one case: filed #1475, trimmed 3,
implemented 3.

The example turned out to still be true one layer down, so the rewrite
says so rather than claiming victory: evaluateValidationRules() is wired
into insert and single-id update only, so a bulk updateMany skips every
rule silently — and for format/json_schema without even the warning,
since it's gated on needsPriorRecord(). Filed as #3106 (bug+security)
rather than fixed here, per this directive. Added lesson: a case label is
not enforcement — check the call site.

A stale example is itself the failure mode the directive warns about —
documentation advertising a state the code no longer has.

Co-Authored-By: Claude Fable 5 <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ion guidance (objectstack-ai#17401)

`PageSchema`'s `visibleWhen` and `permissions` wrong-layer prescriptions both
ended by pointing the author at `assignedProfiles` — "or gate the page with
`assignedProfiles`" and "reach it through `assignedProfiles`". Measured on
2026-09-10, `assignedProfiles` has zero readers in this repo and zero readers in
objectui at `3fbdd4a2d`, so both sentences advertised a capability the runtime
does not deliver, at parse time, in the error whose job is to teach the correct
spelling. That is Prime Directive objectstack-ai#10's exact prohibition. The key is also named
for the concept ADR-0090 D2 removed.

Both prescriptions now state only what the platform actually does. The
`assignedProfiles` declaration and the `profiles:` / `assignedTo:` alias entries
are deliberately untouched: the key's disposition needs a ruling and stays open
on objectstack-ai#16929. Both edited channels fire only from the `unrecognized_keys` path, so
the accept set is unchanged.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ver an ambient NODE_ENV says (objectstack-ai#17927)

Fixes objectstack-ai#12271

Clause-②: no

Dispatched by the `domain:cli` execution seat (objectstack-ai#6024) under triage's
re-grade
[`5650901283`](objectstack-ai#12271 (comment))
(p3 → **p2**, `Task` → **Bug**, file face unlocked to `start.ts`).
Branch base `5741ff10c`, the parked worktree merged forward and **fully
rebuilt** before any reading was taken — freshness proven by content,
not mtime: a symbol introduced by merged commit `eadcde6d8`
(`KNOWN_UNSUPPORTED_JSON_SCHEMA_PATTERNS`) occurs twice in
`packages/cli/dist/commands/generate.js` and twice in its source.

---

## 1. What was actually broken — and it is not what the card's title
says

The card is titled around the `os dev` **compile child** inheriting an
ambient `NODE_ENV=development`. That inheritance is real, but it is a
symptom. Reading the failure output rather than the card:

```
[MODULE_NOT_FOUND] Warning: ModuleLoadError
task: findCommand (doctor)
plugin: @objectstack/cli
message: [MODULE_NOT_FOUND] import() failed to load
         …/packages/cli/src/commands/doctor.ts: Cannot find module './registry'
Require stack:
- …/packages/formula/src/index.ts
```

⭐ The module that failed to load is
**`packages/cli/src/commands/doctor.ts`** — one of the CLI's *own*
command modules. The casualty is the command table, not the user's
config.

`@oclif/[email protected]`'s `lib/config/ts-path.js` skips its TypeScript path
lookup only when `isProd()`, defined in `lib/util/util.js` as a negated
`['development', 'test'].includes(process.env.NODE_ENV ?? '')`. Under
either value it (a) rewrites the command target from the declared
`./dist/commands` to `src/commands` and (b) calls `registerTsx()`. tsx
honours the tsconfig of the **current working directory**, so an
application that maps a CommonJS workspace package to its TypeScript
source for *type* resolution steers this CLI's *runtime* module graph
into `.ts` files — and Node's CJS resolver then walks their
extensionless siblings and knows nothing about `.ts`.

Three consequences the card did not have:

- It is **not specific to `compile`**, or to any command. `os --version`
reproduces it.
- It reaches invocations with **no parent at all** — `os serve --dev`,
`os start` — so ⛔ no child-environment scrub can fix it. This is the
reason triage measured shape B as not fixing the card.
- `NODE_ENV=test` is the **second** value oclif treats as
non-production, and it had never been measured on this card. vitest
exports it on its own worker.

## 2. The spawn census (acceptance item 3) — measured here, with
controls

⛔ Not taken from the card, the previous dev's report, or triage's
comment. Read through **TypeScript's own parser**
(`ts.createSourceFile`), resolving `child_process` bindings through
named / aliased / namespace / default imports, `require()` destructuring
and `await import()` destructuring, then matching call expressions
against those bindings. Script: `census.mjs`, run at `149135743`.

**Controls, both required to pass before the census prints:**

| control | expectation | result |
|---|---|---|
| positive fixture — 7 spawn calls across every import spelling, plus
one look-alike identifier that must NOT match | 7 | **7 — PASS**
(`spawn, spawnSync, execFileSync, exec, execFile, execSync, fork`) |
| negative fixture — a local binding *named* `spawn`, and the word
inside a string literal | 0 | **0 — PASS** |

**Census — 6 child-process spawn sites, not 5:**

| site | API | `env` handed to the child |
|---|---|---|
| `dev.ts:349` | `spawnSync` | ⛔ no `env` property — inherits
`process.env` whole |
| `dev.ts:586` | `spawn` | `env: localEnv` (a spread of `process.env`) |
| **`dev.ts:724`** | **`execSync`** | ⛔ **no `env` property — inherits
`process.env` whole** |
| `dev.ts:821` | `spawnSync` | `env: process.env` |
| `start.ts:238` | `spawnSync` | `env: process.env` |
| `start.ts:435` | `spawn` | `env: localEnv` |

Dark-instrument control: the API names occur as bare words 13 times in
`dev.ts` and 6 times in `start.ts`; the site counts are 4 and 2, and the
gap is imports and prose.

⚠️ **`dev.ts:724` is the disagreement, and it is a real site all four
previous numbers missed.** The card, the previous dev's report, triage's
own count and the seat's re-count all say **5**. They agree because they
all counted `spawn` / `spawnSync`. `dev.ts:724` is an `execSync` reached
through `const { execSync } = await import('child_process')` — a dynamic
import inside a function body, invisible to a scan looking for the two
static names. It is the workspace-root branch of `os dev`, which
delegates to `pnpm [--filter X] dev`.

⛔ **It is deliberately not touched**, and that is a classification
rather than an omission: it spawns a **user's own workspace script**,
not the `os` CLI. `NODE_ENV=development` is meaningful and expected
there, and scrubbing it would change the behaviour of arbitrary user dev
scripts. It is in the census because the census was asked for; it is
outside the defect class because the defect class is *this CLI resolving
its own commands*.

## 3. Which sites were scrubbed, and why that set is **empty**

**None.** The fix is one declaration in `packages/cli/bin/run.js`:

```js
settings.enableAutoTranspile = false;
```

oclif checks `settings.enableAutoTranspile ?? settings.tsnodeEnabled`
**ahead of** `isProd()`, so `false` skips both the source redirect and
the tsx registration under every value of `NODE_ENV`.

The argument for the entry over the six sites, point by point:

- **A child-env scrub cannot satisfy acceptance item 1.** `os serve
--dev` and `os start` are top-level processes. There is no parent.
- **It is the contract this repo already wrote down.** `bin/run.js` is
the BUILT entry (`bin.objectstack` / `bin.os`; `package.json` declares
its command table over `./dist/commands`); `bin/run-dev.js` is the
SOURCE entry. `scripts/check-cli-test-child-env.mjs` rule 3 enforces
exactly that division on every *test* that spawns the CLI, with no
baseline. The entry had simply never asserted it about itself.
- **Every spawn site hands the child `process.argv[1]`**, i.e. this same
entry — so fixing the entry fixes all of them, plus every future one,
with no per-site convention to keep true.
- **A per-site scrub changes what the child DOES.** `NODE_ENV` is read
by product code (`start.ts`'s production default, the crypto posture,
plugin-auth's origin gate). Rewriting it at six spawns to work around a
module-resolution bug is the lenient-consumer shape Prime Directive objectstack-ai#12
refuses. The entry-point declaration changes only *which of two
already-declared code paths* oclif loads.
- ⛔ **Not a `TSX_TSCONFIG_PATH` pin either** (what `bin/run-dev.js`
carries). That shim genuinely executes TypeScript, so all it can do is
aim the transpiler; it cannot even do that in-process and pays a full
re-exec. This entry executes no TypeScript, and a published install has
no `packages/cli/tsconfig.json` to aim at — `files` names `dist` only.

`dev.ts` and `start.ts` carry comment-only changes, both required by
Prime Directive objectstack-ai#10 because **this change** moved what they claim. See
§6.

## 4. Two-leg ablation with the `production` control (acceptance items 1
and 2)

Run from the committed state. `exit 124` = compiled, booted, and **still
serving** when the 60 s timeout killed it; `exit 1` = died. `NODE_ENV`
is the only variable; `OS_SECRET_KEY` supplied so the production arm is
not refused on crypto policy. Exit codes captured before any pipe.

**End to end, `examples/app-crm` and `examples/app-showcase` (both carry
sibling-src `paths`):**

| invocation | leg | `NODE_ENV=development` | `NODE_ENV=production`
(control) |
|---|---|---|---|
| `os dev --compile --fresh` | fix REVERTED | **exit 1** · 9 hits of
`Cannot find module './registry'` | exit 124 · 0 hits |
| `os dev --compile --fresh` | fix IN PLACE | **exit 124** · 0 hits |
exit 124 · 0 hits |
| `os serve --dev` | fix REVERTED | **exit 1** · 9 hits | exit 124 · 0
hits |
| `os serve --dev` | fix IN PLACE | **exit 124** · 0 hits | exit 124 · 0
hits |

Identical at both apps, 8 legs per side.

**`os compile`, all four example apps, published entry `bin/run.js`:**

| leg | showcase | crm | todo | multi-package |
|---|---|---|---|---|
| fix REVERTED, `NODE_ENV=development` | **1** | **1** | 0 | **2** |
| fix REVERTED, `NODE_ENV=production` (control) | 0 | 0 | 0 | 0 |
| fix IN PLACE, `development` / `test` / `production` | 0 | 0 | 0 | 0 |

`examples/app-todo` — the one app whose tsconfig carries no `paths`
block — is the only armed pass, so the failures map 1:1 onto the
sibling-src population.

⚠️ **The entry the card's own probe used.** `bin/run-dev.js`, invoked
correctly (under `tsx`, which its shebang requires), is **0/0/0/0 under
both `development` and `production`** — its `TSX_TSCONFIG_PATH` re-exec
mitigation works. That is why the card read "latent" for weeks: the
probe went through the mitigated entry while users run the unmitigated
one. ⛔ Re-measured here rather than carried over, and a note for the
next reader: running `run-dev.js` under plain `node` answers 1/1/0/2 in
**both** `NODE_ENV`s — that is the shim failing to load its own `.ts`
import, not this defect, and it is an easy false reading to take.

**The ablation legs are one-off and left nothing behind.** Every
mutation was proved to have reached disk before its run (injected marker
counted, removed line counted — ⛔ never a bare `git diff --stat`), every
restore leg is `git checkout HEAD -- packages/cli/bin/run.js` (⛔ never a
bare `git checkout --`, which restores from the polluted index), every
restore was proved by `git hash-object` against the HEAD blob rather
than by an exit code, and every mutating script carried `trap … EXIT INT
TERM`.

## 5. The regression pin, and why it needs a control

`packages/cli/test/published-entry-node-env-source-reroute.test.ts` — 5
cases, 5 passing.

Every assertion in it is an **absence** (no reroute, no signature, exit
0), and an absence passes just as well over a fixture that arms nothing.
So one leg defeats the declaration **inside the child** —
`test/fixtures/published-entry-auto-transpile-neutraliser.mjs`, an
`--import` preload installing an accessor whose setter swallows the
entry's assignment (a plain write loses to it; a non-writable property
makes ESM strict mode throw) — and asserts the card reproduces
**verbatim**. A second control leg runs the neutralised child under
`production` and is green, pinning that `NODE_ENV` is the variable.

⛔ Nothing touches `bin/run.js` on disk: a crashed or timed-out run must
not leave the entry point neutralised for the next reader.

⚠️ The fixture is built in the test's own temp dir rather than pointed
at `examples/app-crm`, so the suite reads nothing outside `packages/cli`
— pointing at the example app would be a cross-package test input,
needing a declaration in `scripts/cross-package-test-inputs.mjs` and a
mirrored `turbo.json` entry that widens this package's test cache key
over another package's whole source tree. Recorded because it cost a
measurement: the **first** fixture attempted mapped the specifier onto
an ESM `.ts` source inside `packages/cli` and stayed green in all six
legs — the failure needs Node's **CJS** resolver walking a `.ts` file's
siblings, so the trap has to be CommonJS with an extensionless relative
`require`. A fixture that arms nothing is the exact vacuity the control
exists to refuse.

## 6. `start.ts:419-421` (acceptance item 5) — the comment is true
again, ⛔ no card needed

```
// NODE_ENV is only forced to production when the user has not set it.
// Allows `NODE_ENV=development objectstack start` to work for debugging.
if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';
```

Measured at `examples/app-crm` on unmodified `5741ff10c`:
`NODE_ENV=development objectstack start` → **exit 1**, 9 hits of the
card's signature. `NODE_ENV=production` → exit 124. So the sentence
advertised a debugging mode the runtime did not deliver (Prime Directive
objectstack-ai#10).

⭐ Which half was wrong matters: the **line** was always correct — the
operator's value does reach the child. What failed was the invocation
the sentence names, and it failed *before `localEnv` was ever built*, in
this process. So the fix makes the sentence true rather than needing a
behaviour change, and the comment now says so and points at the pin
instead of asserting it on its own authority. ⛔ No separate card is
filed, because there is no separable behaviour change left to file.

`dev.ts:328-348`'s NOTE is corrected for the same reason: it stated the
consequence ("`os dev` dies before the server starts") as a property of
*writing* `NODE_ENV` on the child, and this change makes that
consequence unreachable. The rule itself stays — a source that asserts a
loader-activating value is a different claim from an entry that refuses
to act on one, and it is the half `child-env-source-loader.pin.test.ts`
can see.

## 7. Fence: ⛔ no general `paths`-resolution gate

None is added. Nothing here parses a tsconfig, and the pin's fixture is
a tsconfig the test **writes**, never one it reads. The objectstack-ai#8020 / objectstack-ai#8108
class is untouched.

## 8. The gate interaction this PR could not avoid, stated rather than
buried

The new pin spawns `bin/run.js` with a `development` / `test` child —
exactly what `check:cli-test-child-env` rule 3 refuses. Three
`DELIBERATE_REROUTE` entries are added, which is the mechanism that gate
designs for a site whose `NODE_ENV` is its independent variable.

⚠️ They are the **inverse** of the two entries already there, and the
registry now says so: the existing pair needs the reroute, this file
asserts the reroute does **not** happen. The gate's header gains a
paragraph recording that the published entry now refuses it, what rule 3
therefore still buys (it is what would notice the declaration being
dropped), and ⛔ that these entries are not precedent for a spawner that
wants `src/` — that is still `bin/run-dev.js`. The gate's own oclif
table is untouched and still correct: it was measured against
`Config.load()` with **default** settings, which is what every other
built oclif entry still gets. Its self-test census pin moves from five
files to six, with the reason beside the new member.

## 9. Docs — hand-read, because the drift tool declared it could not
cover this file

The docs-drift advisory reports `packages/cli/bin/run.js` as yielding
**no anchor**, so the pages documenting the published entry's behaviour
are outside its run. Hand-read instead: every hand-written page naming
`NODE_ENV` (8, excluding the 6 release-owned pages, which are ⛔
read-only and were not edited), plus every page naming the CLI entry,
`tsx`, or auto-transpilation.

⭐ **Nothing is falsified. Two pages document the exact invocation that
was broken, and this change is what makes them deliverable:**

- `content/docs/protocol/kernel/http-protocol.mdx:206-209` — *"Anything
that boots the runtime without `os dev` — a bare `os serve`, an embedded
host, a hand-written container entry point — must now set
`NODE_ENV=development` explicitly to keep being advertised as such."*
Following that instruction in a `paths`-carrying project exited 1 before
this change.
- `content/docs/deployment/environment-variables.mdx:33-36` — *"In dev
(`os dev`, or `NODE_ENV=development`) a busy port auto-hops…"*. Same:
the documented way to opt into dev behaviour was the thing that broke.

The other six (`deployment/cli.mdx`, `deployment/self-hosting.mdx`,
`permissions/authentication.mdx`, `plugins/packages.mdx`,
`protocol/kernel/config-resolution.mdx`, `upgrading.mdx`) read
`NODE_ENV` for auto-reconcile posture, the dev seed gate, sample app
code, plugin-dev's production refusal, config-file selection and
migration policy — none touches module resolution, and none moves.

⭐ **One page changed this PR's own claim.**
`content/docs/plugins/index.mdx:399-408` documents that `os plugins …`
is **not a registered command**: `@oclif/plugin-plugins` sits in
`devDependencies` and oclif's core-plugin loader only matches names
under `dependencies`. Verified here rather than taken on trust — `os
--help` lists **34** topics and **zero** of them is `plugins`, the topic
count being the control that makes the zero a reading. ⇒ the
linked-TypeScript-plugin cost this PR originally stated is **unreachable
today**; the docblock and the changeset were corrected to say so, with
the condition under which it would have to be revisited. ⛔ No docs page
needs an edit, and none was made.

## 10. Verification

Everything below at HEAD `c6ea03075`, exit codes captured **before any
pipe**, heavy runs serialised through `scripts/pm/os-verify-lock.sh`
(slot `dev-12271`).

| run | result |
|---|---|
| `pnpm build` (full, post-merge rebuild) | `VERDICT command-exit 0` —
73/73 tasks |
| `pnpm --filter @objectstack/cli build && … typecheck` | `VERDICT
command-exit 0` |
| `vitest run --project unit` | `VERDICT command-exit 0` — **204 files /
2935 tests passed** |
| `vitest run --project integration` | `VERDICT command-exit 0` — **47
files / 409 tests passed** |
| `pnpm lint` (full repo union, ⛔ not narrowed) | **exit 0** |
| the 81 families from `dispatch-gates --commands --repo
objectstack-ai/objectstack` | **81 of 81 exit 0** |

`node scripts/pm/dispatch-gates.mjs --ran … --repo
objectstack-ai/objectstack` reconciles: *"81 derived famil(ies)
accounted for — 81 run, 0 NOT-MEASURED (a DERIVED zero — all 81 recorded
an exit code and none of them is 3)"*, with `pnpm lint` recorded as one
run beyond the union. Re-derived after `git fetch origin main` advanced
it to `dbea1756d`: the family set is **identical**, 81 before and after,
zero added and zero removed.

⚠️ Two readings stated rather than smoothed over:

- `pnpm check:pm-dispatch-gates` first returned **124** — that was *my*
420 s per-command timeout, ⛔ not a gate verdict. Re-run with a longer
budget: **exit 0**, 1678 self-test cases pass. The record carries the 0,
and this note carries the first reading.
- The tree is **4 commits behind `origin/main`** (`dbea1756d`), and
`dispatch-gates` flags one derived-from file as stale across that range:
`scripts/engine-double-contract.pinned.json`. Its diff and mine share
**zero files** — the four commits touch `spec`, `lint`, `plugin-auth`,
`client`, `runtime`, `metadata-protocol` and `examples`, and no
`packages/cli` path — so nothing is re-scoped; the merge queue rebuilds
onto current `main` regardless.

## Acceptance notes

- **noted, not filed:** `dev.ts:724`'s `execSync` hands `pnpm [--filter
X] dev` the parent environment whole, with no `env` property. Not a
defect — that child is a user workspace script, for which
`NODE_ENV=development` is correct — but it is the sixth spawn site, and
the four previous counts of "5" are all explained by it being reached
through `await import('child_process')` rather than the static import.
Carrier: the census table above; any future card on `os dev`'s
workspace-root branch inherits it.
- **noted, not filed:** `check:cli-test-child-env` rule 3's *specific*
harm — a `bin/run.js` spawn silently executing `src/` — is now closed at
the source for this repo's entry, so the rule can no longer fire for the
reason its header gives. It is not dead: it keeps a built-entrypoint
spawn readable about the `NODE_ENV` it means, and it is the instrument
that would catch the declaration being dropped. Recorded in the gate's
own header by this PR rather than left for the next reader to discover
while deleting it.
- **noted, not filed:** `examples/app-showcase`'s tsconfig now carries
**three** sibling-src `paths` entries (`formula`, `plugin-email`,
`lint`), against the two the card tabulates, and
`examples/app-multi-package` — a fourth example app the card predates —
redirects `@objectstack/spec` itself. Population growth only; objectstack-ai#8249 owns
it.
- **noted, not filed:** `examples/app-multi-package` fails with a
*different* surface under the armed environment (`The requested module
'@objectstack/spec/api' does not provide an export named 'ErrorCode'`,
then `command compile not found`, exit 2) rather than the `./registry`
signature. Same cause, different first casualty; green after this change
like the rest.

Authored by the `os-dev` executor in session
`session_01TSf4DV7ziu4V5j73e46b7c`, dispatched by the `domain:cli` seat
(objectstack-ai#6024). (Recorded here as prose: this PR body has been edited, and the
edit channel appends its own attribution block regardless of what is
sent — measured twice on this PR.)

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

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ld-type table — all 74 refused pairs answer DATASET_INVALID at one compile door (objectstack-ai#17560) (objectstack-ai#18011)

Fixes objectstack-ai#17560

Executes the director-seat ruling on this card (decision batch objectstack-ai#127 item
3, comment 5651572190) in **one pass, not per field class**: `min` and
`max` are judged by `AGGREGATE_FIELD_TYPE_COMPATIBILITY` like every
other aggregate, and all 74 pairs that were
refused-by-the-table-and-enforced-by-nothing now answer
`DATASET_INVALID` / **400** at the compile door.

## What the tree said before this

Four declarations, three answers, one pair:

| declaration | `min` x `text` |
|---|---|
| `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec, objectstack-ai#16353) | refused |
| `dataset-compiler`'s compile leg | never judged — `if
(!DERIVING_AGGREGATES.has(aggregate)) return;` |
| `measureResultType` (service-analytics, objectstack-ai#15768) | a supported
`'string'` result |
| two shipped test files, in prose | "ruled C — the table is to be
AMENDED to accept it, tracked as objectstack-ai#17513" |

The fourth row had nothing behind it: objectstack-ai#17513 is closed as a duplicate of
this card carrying zero rulings, and the one recorded ruling on this
table — decision batch objectstack-ai#59 on objectstack-ai#16099 — refuses those rows. The ruling
settled all three sub-questions together because one shared fixture
drove members of both halves.

## The ruling's Execution list, line by line

- **`dataset-compiler.ts`** — the `DERIVING_AGGREGATES` scope condition
is gone; `assertAggregateFieldTypeCompatible` judges all six aggregates
through the same `DATASET_INVALID` / 400 door. The refusal message now
names the divergence each aggregate class really has (`min`/`max` SELECT
a stored value and diverge on ORDER — collation-dependent for text,
absent altogether for `jsonb`; `sum`/`avg` DERIVE a number and diverge
on arithmetic) and prescribes accordingly. The `sum`/`avg` sentence is
byte-identical to what shipped, so objectstack-ai#16099's and objectstack-ai#16778's message pins
are untouched.
- **`measureResultType`** — asks `isAggregateCompatibleWithFieldType`
before it answers, so the rule and the table agree **by construction**
rather than by review. `STRING_SOURCE_FIELD_TYPES` and the `formula`
branch are retired; `min`/`max` over the temporal class still answers
`'time'`.
- **Tests** — the shared fixture in `measure-result-type.test.ts` is
re-aimed off refused pairs following the objectstack-ai#16737 precedent in the same
file; the **two conditional pins are FLIPPED, never deleted**
(`aggregate-nontemporal-measure-refusal.test.ts` "min / max are NOT
judged by this gate", relocated by text rather than by the ruling's line
number — see Deviations; `aggregate-datetime-measure-refusal.test.ts` "a
min over a TEXT field still compiles here"), each naming batch objectstack-ai#59 and
this ruling; the negative control that accepted pairs still compile
(`min` x `number`) is **kept** and joined by four more; the eight
`objectstack-ai#17513` citations are rewritten to this card.
- **The table's TSDoc "override" paragraph** is rewritten as settled
ground — the overridden opinion is retired, not standing beside it.
- **Breaking** — `minor` under the launch-window convention with the
BREAKING banner, plus a new ADR-0087 **semantic migration entry**
(`dataset-measure-selecting-aggregate-field-type-refused`, protocol
major 18) writing the structured TODO that names the measure and the
field type. No lossless conversion exists, which is why it is a semantic
TODO and not a D2 conversion.

## One consequence the ruling implies and did not name

Retiring the `formula` branch left `measureResultType`'s third input
(`formulaReturnType`, objectstack-ai#16236) with no reader, and
`AnalyticsServiceConfig.sourceFieldMeta`'s `returnType` key with no
consumer. Both are **removed**: a declared input nobody reads is the
declared-not-enforced shape Prime Directive objectstack-ai#10 refuses.
`FieldSchema.returnType` itself is untouched — display formatting and
validation are its other declared consumers.

## Verification

Every exit code captured **on the command** (redirect first, `EXIT=$?`,
then read), never after a pipe. All figures below are from the FINAL
tree, `035a41c7e` — the second merge of `origin/main` (`8261ff717`) that
this branch carries, built whole. They were re-taken in full on this
tree after the patch round; the pre-patch tree `399d12244` read the same
shape.

Build and tests, through `scripts/pm/os-verify-lock.sh`
(`OS_VERIFY_LOCK_SLOT=issue-17560`; four holds across both rounds,
VERDICT command-exit 0 on every one — 590s / 21s / 795s / 890s held):

```
pnpm install --frozen-lockfile                                :: exit 0
pnpm build                                     (whole repo)   :: exit 0
pnpm --filter @objectstack/spec check:generated               :: exit 0   all 15 generated artifacts up to date
pnpm --filter @objectstack/service-analytics exec vitest run  :: exit 0   111 files / 2392 tests passed
pnpm --filter @objectstack/spec test                          :: exit 0   476 files / 13556 tests passed
pnpm --filter @objectstack/service-analytics typecheck        :: exit 0
pnpm --filter @objectstack/spec typecheck                     :: exit 0
pnpm lint                                      (whole repo)   :: exit 0
```

Gate families derived from the **actual** changed paths, not from a
list: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` — 15 paths, **87 commands**, no STALE TREE
warning on this tree. All 87 re-run on `035a41c7e` after the patch
round, each recorded as `COMMAND :: exit CODE`, and reconciled:

```
dispatch-gates --ran :: exit 0
  87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED
  (a DERIVED zero — all 87 recorded an exit code and none of them is 3)
```

⚠️ On the pre-merge tree four of them answered **exit 3 — PREREQUISITE
NOT MET** (`check:doc-formula-expressions`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`): each reads built output and the closure was
not built yet. That is each gate's own "nothing was measured" code,
neither a pass nor a finding. All four were re-run after the whole-repo
build and all four exit 0.

⛔ Not a complete account of what CI runs: the 48 artifact-roster
families, the 11 declared wide-population families, the 5 path-scheduled
CI jobs and the always-runs tail are each outside the derived 87, as
`dispatch-gates` prints.

### Reverse verification — direction predicted BEFORE running, and it
held

Ablation: the deleted scope condition put back as `if (aggregate !==
'sum' && aggregate !== 'avg') return;` in `dataset-compiler.ts`, on the
committed fix, under the same verify lock.

```
HEAD blob 9062405
mutated blob b2935944651c57ba3cd8ec759a3303fb33ff105c   (differs; an equal or empty hash was coded to abort)
on-disk proof   anchor occurrences 1 before / 1 after · injected marker 0 before / 1 after
                (occurrence counts on the mutated text — never an editor's exit code, never a --stat)
MUTATED  :: exit 1   4 files failed · 13 of 209 tests red
RESTORED :: exit 0   4 files passed · 209 of 209 green
restore proof   blob back to 9062405… · marker count 0 · git diff HEAD clean · WHOLE-TREE git status --porcelain empty
                (trap '<restore>' EXIT INT TERM, paths absolute from git rev-parse --show-toplevel)
```

Predicted: red in the ordinary direction — with the scope back, the
compile SUCCEEDS, so every refused-pair case fails on "expected a
refusal, none was thrown" rather than passing vacuously on an empty
result. Observed: exactly that, spread across all four files — the two
flipped pins, the re-aimed section E, and the formula end-to-end
section.

`node scripts/ablation-dist-preflight.mjs @objectstack/service-analytics
ABLATION-17560-SCOPE-RESTORED` **:: exit 1**, reported rather than
worked around: the package has no `dist/` in this tree at all. Its
prerequisite is inapplicable here rather than unmet — these suites
import their subject relatively (`../analytics-service.js`), so they
resolve to `src/`, and the 13 reds on the mutated tree beside 209 greens
on the restored one are the direct evidence that the edit reached the
subject.

## Acceptance notes — noted, not filed

- The table's module TSDoc still calls itself "the contract both
consumer legs execute — the compile-time refusal in the dataset compiler
and the authoring-time lint rule". The authoring-time leg still does not
exist; `packages/lint` never calls the predicate. Carrier: **objectstack-ai#16354**,
open and labelled `pm:blocked`, and the ruling leaves it there ("The
lint leg is unchanged"). Not filed.
- A **ninth** `objectstack-ai#17513` citation exists outside `packages/`, in the
still-pending changeset
`.changeset/deriving-aggregate-nonnumeric-field-refused.md` (objectstack-ai#16099's).
It ships in the same release as this one and its "min / max are still
not judged here" section would contradict this entry in one compiled
CHANGELOG, so it carries a superseded-within-the-same-release-window
note and its tracker pointer is repointed. Reported rather than assumed
in scope.
-
`packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts`
(objectstack-ai#16778's entry) carried two scope sentences this change makes false —
"the compile leg is scoped to that class and to nothing else" and "a
measure over a field of any OTHER class is neither refused nor certified
here" — plus a pointer to objectstack-ai#16785, an issue number that does not resolve.
objectstack-ai#16099's own changeset recorded that widening it "is a `packages/spec`
edit this card is fenced out of and is reported to the `domain:spec`
seat rather than done here"; this is that seat and this card is the
carrier, so the two sentences are corrected in place and now name all
three entries.

## Patch round — the at-tier contract review's three FAIL grounds,
closed

The review (comment `5653288459`) passed the behaviour in full — one
door for all six aggregates, 74 re-derived off the raw enum, table
unamended, `formula` refused on the storage ground, both pins flipped
not deleted, controls kept, `Clause-②: no` correct — and upheld the
`returnType` removal as compelled by the ruling's own words. What failed
was the truth of the contract TEXT shipping beside it. All three fixes
are text-only; ⛔ no behaviour, pin, control or the `returnType` decision
was touched.

**T1 — a same-release changeset said the opposite, twice.**
`.changeset/16236-formula-return-type-measure-column.md` (still pending,
so it compiles into the same CHANGELOG block as this entry) promised a
typed formula measure column and a `returnType?: string` fourth member
on `sourceFieldMeta`. It now carries the same
superseded-within-the-release-window treatment already given to
objectstack-ai#16099's, at the head and again on the `sourceFieldMeta` paragraph. This
PR's own changeset now states that the removed key **was never
released** and gives the host its one line.

> Reading: released `packages/services/service-analytics/CHANGELOG.md`
(17.4.0) — `16236` 0 hits, "fourth member" 0 hits. Lit controls on the
same file: `15768` 1, `measureResultType` 2, `sourceFieldMeta` 4. Dark
control `qzwxrt4419` 0. Stronger still, the one `returnType` hit in that
released text says in as many words that the key "is not on
`AnalyticsServiceConfig.sourceFieldMeta`'s return shape". And `git grep
"fourth member" origin/main -- .changeset/16236-…` is 1 — the adding
changeset is still pending on `main`, so no tarball ever carried the
key.

**T2 — a dead tracker pointer the ruling itself named.**
`.changeset/dataset-measure-aggregate-field-type-refused.md` (objectstack-ai#16778's,
pending) still said the string rows were "under objectstack-ai#16785, ruled C — the
table itself is to be amended". Corrected where it stands: `16785`
resolves to nothing, batch objectstack-ai#127 found no ruling C behind the citation,
and the table is not amended. The file also gains the superseded banner,
and its two other now-false sentences — `sum` over a `percent` "compiles
exactly as it did before", and `avg`/`sum` over temporal being "the only
pairs whose behaviour changes in this release" — are marked where they
stand.

> Reading: `git grep 16785` over the whole repo was **1** hit, all of it
in that file — the ruling's own claim reproduced. It is now 2 in the
same file, both naming it as the retired pointer; repo-wide it appears
nowhere else. Dark control `qzwxrt4419` 0.

**T3 — the corrected objectstack-ai#16778 registry entry miscounted itself.** It
claimed to be "the FIRST of three" and pointed at "the two entries that
widened it — objectstack-ai#16099 …". objectstack-ai#16099 registered **no entry**: its changeset
declares `not-required (already-registered
dataset-measure-aggregate-field-type-refused)`, so its widening rides
this id. The `surface` now reads "ONE OF TWO", names that `not-required`
relationship explicitly, and says there is no third;
`acceptanceCriteria` keeps its one true sentence (every refused pair is
refused at the compile door at major 18) and drops the phantom entry. ⛔
Its scope criterion itself is **not** widened — that is objectstack-ai#16099's open
ask and the seat is tracking it separately. `registry.ts` regenerated.

> Reading: semantic entries whose id contains `aggregate-field-type` =
**2** (lit control: 211 entries in the directory; dark control
`qzwxrt4419` 0). No entry file is named for objectstack-ai#16099 (`git grep -l
deriving-aggregate|nonnumeric` over `entries/` exits 1). In the
regenerated `registry.ts`: "ONE OF TWO" 1, "no third entry to look for"
1, "FIRST of three" 0, "the two entries that widened it" 0 — and
repo-wide both stale phrases are 0.

⚠️ Also taken, declared rather than smuggled: the review's **F5 nit** —
the changeset's opening list read as exhaustive while naming 10 of 37
types. It now says "any of the **37** field types outside the numeric,
temporal and boolean classes — for example …", and points at the
ADR-0087 entry for the full list. One phrase, in a file T1 already
reopens.

⛔ Not done, deliberately: widening objectstack-ai#16778's `acceptanceCriteria` to
`sum`/`avg` over every class (objectstack-ai#16099's open ask, fenced out by the
dispatch), and the four items the review listed as "not this PR's to
fix".

## Landing

⛔ **Draft, and it stays that way until the seat's contract review clears
it.** The claim grades this `Clause-②: no` — nothing starts being
accepted, this pulls code back to the declared contract — but the
**path** limb of the clause-② enqueue gate fires on
`packages/spec/src/**` regardless of the declaration, so
`needs:contract-review` is carried on both the PR and the card. ⛔ Not
flipped ready, ⛔ auto-merge not armed, ⛔ not queued.


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

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…sert identity (objectstack-ai#18381)

Fixes objectstack-ai#17135

Clause-②: yes

The rule's first run on a real application reported **12 fields, and all
12 were on screen or load-bearing that day**. Triage ruled the direction
(`objectstack-ai#17135` comment `5620725440`): *"⇒ Teach the rule about both consumer
paths"*, with the guardrail *"⛔ Do not fix it by exempting `hidden`
fields wholesale — a `hidden` field with genuinely no consumer is still
a finding; the point is that a seeder-only identity column **has**
one."*

Both consumers are now read off the spec, the way the rule's existing
two exemptions (`injectedColumnsFor`, `resolveDisplayField`) already
are.

## 1 · The synthesized layout is a display consumer

`deriveFieldGroupLayout` (`@objectstack/spec/data`, ADR-0085 §5) is the
single derivation every renderer applies — form, detail, drawer,
designer. It places a field by its `group` membership in the object's
declared `fieldGroups`, so **no `fields: [...]` array anywhere names
it**. A field the derivation puts in a **keyed** section is drawn, and
is now credited as a `display` site.

This follows the **previews-count-as-consumers ruling** (2026-08-10,
[objectstack-ai#7131 comment
5237224148](objectstack-ai#7131 (comment))),
which objectstack-ai#17135 pointed at through objectstack-ai#7427: *"A designer preview that renders
a key to a human is a runtime consumer."*

**The trailing untitled bucket is deliberately NOT credited**, and this
is the load-bearing line in the change. `deriveFieldGroupLayout`
collects every visible field that named no declared group into a
trailing keyless section, and returns `null` — "callers fall back to
their existing flat/auto layout" — for an object declaring no groups at
all. So *every visible field renders somewhere*. Crediting "renders on
the synthesized layout" in full would leave `field-no-consumers` able to
report `hidden` fields and nothing else, which is switching the gate off
by another name. What is credited is the author's **placement**; what is
not is the fallback that would draw anything.

⚠️ **Reach, measured, for the maintainer.** On `hotcrm@965933b`, **326
of 331 declared fields** (98%) sit in a declared group. The 12 findings
were 11 of them, so the acceptance set widens by 11 fields on this
application — but on an app that groups everything, the display half of
this rule now rests on the author's grouping decision. That is the price
of the ruled direction and it is stated rather than buried.

## 2 · An upsert identity inside a carrier root is a read

`bucketFor` returned `carrier` for a carrier root before anything else
could ask. But a seed's `externalId` (`SeedSchema`: *"Field (or
composite list of fields) matched for the uniqueness check"*) and an
import mapping's `upsertKey` name the column the loader **matches on** —
it reads that column on every row to decide insert from update. A seeded
*value* is still a carrier; the *identity* is the one read a carrier
root holds.

`crm_forecast.seed_key` is exactly the shape triage described: `hidden:
true`, `readonly: true`, *"Written only by the seed loader; empty on
every real snapshot"*, and `src/data/forecast.seed.ts` declares
`externalId: 'seed_key'`. It is consumed **by being an identity**.

⛔ **Nothing exempts `hidden` as a category.** The derivation never draws
a hidden field, so path 1 gives it nothing; path 2 rescues it only when
an upsert actually matches on it. The matched-pair control below is the
proof.

## Acceptance evidence — the false-positive rate on a real app

⭐ Triage: *"The acceptance evidence is the false-positive rate on a real
app, ⛔ not that the unit tests pass — the unit tests passed when it
shipped."*

**Instrument.** `hotcrm` has no checkout here, so one was cloned
(`objectstack-ai/hotcrm`, public, read-only) and its authored stack
dumped to JSON, with functions replaced by their source text — which is
exactly what the rule does with a function node
(`Function.prototype.toString.call(node)` → `scanText`), so the dump is
behaviourally equivalent for this rule. **The instrument is calibrated,
not assumed**: at `hotcrm@965933b` the app's own `npx objectstack lint
--json` (its pinned `@objectstack/[email protected]`) answers `errors: 0 ·
warnings: 13 · suggestions: 12` — the card's reading byte for byte —
with 12 `field-no-consumers` rows, and the harness over the same tree
reproduces **the same 12, same objects, same declaration paths**.

**Tree.** `objectstack-ai/hotcrm@965933b` — `feat(platform): migrate
onto the @objectstack/* 17.4.0 line (hotcrm#1814)`, the commit the 12
were reported on. (Not `v3.0.0`: the app removed `crm_product.tax_rate`
and later gave four fields consumers and the contact form its mailing
block, so neither the tag nor `main` is the tree the card measured.)

### 12 → 0, per field

| # | object · field | before | cleared by | the consumer it really has
|
|---|---|---|---|---|
| 1 | `crm_account.logo` | carrier-only, 4 | layout | `group:
'branding'` |
| 2 | `crm_article_feedback.comment` | carrier-only, 4 | layout |
`group: 'basic'` |
| 3 | `crm_campaign.description` | carrier-only, 11 | layout | `group:
'basic'` |
| 4 | `crm_campaign_member.added_date` | carrier-only, **57** | layout |
`group: 'basic'` |
| 5 | `crm_contact.mailing_street` | carrier-only, 5 | layout | `group:
'mailing_address'` |
| 6 | `crm_contact.mailing_city` | carrier-only, 5 | layout | `group:
'mailing_address'` |
| 7 | `crm_contact.mailing_state` | carrier-only, 5 | layout | `group:
'mailing_address'` |
| 8 | `crm_contact.mailing_postal_code` | carrier-only, 5 | layout |
`group: 'mailing_address'` |
| 9 | `crm_contact.mailing_country` | carrier-only, 5 | layout | `group:
'mailing_address'` |
| 10 | `crm_contract.description` | carrier-only, 9 | **either** |
`group: 'basic'`, and the seed matches on it (`externalId:
'description'`) |
| 11 | `crm_forecast.seed_key` | carrier-only, 12 | **identity only** |
`data` seed `externalId: 'seed_key'`; `hidden`, so the layout gives it
nothing |
| 12 | `crm_quote_line_item.line_number` | carrier-only, 23 | layout |
`group: 'basic'` |

**Nothing remains: 12 → 0.** Every one of the 12 carries a `group`
naming a declared `fieldGroups` entry, which is what the app's own
CHANGELOG says about them — *"every one of them still renders to users
through its `fieldGroup` on the synthesized detail layout, which the
rule does not resolve."*

The per-field column is not asserted, it is **ablated**. Each path was
removed from the committed file by anchored replacement (absent or
non-unique anchor exits non-zero), the mutation was proven on disk by
literal counts before reading any verdict, and each leg restored with
`git checkout HEAD -- PATH` proven by blob identity against the `HEAD`
blob plus an empty `git diff HEAD`:

| leg | live paths | findings |
|---|---|---|
| `main` | neither | **12** |
| ABLATED-A | identity only | **10** (the layout accounts for 11,
overlapping on objectstack-ai#10) |
| ABLATED-B | layout only | **1** — `crm_forecast.seed_key` (the
identity accounts for 2) |
| HEAD | both | **0** |

### ⭐ The other half: does a real defect still get reported?

⛔ Showing the 12 disappear proves nothing on its own — switching the
gate off does that too. Six deliberately unconsumed fields were injected
into **the same real hotcrm stack**, nothing reading, displaying,
seeding, translating or granting any of them:

| control | shape | reported? |
|---|---|---|
| `crm_contact.ctl_ungrouped` | visible, no `group` → trailing flat
bucket | ✅ reported |
| `crm_contact.ctl_undeclared_group` | `group` naming a group the object
never declared | ✅ reported |
| `crm_contact.ctl_hidden_grouped` | `hidden`, in a **declared** group |
✅ reported |
| `crm_contact.ctl_hidden_readonly` | `hidden` + `readonly`, in a
declared group | ✅ reported |
| `ctl_plain.ctl_orphan` | visible, on an object declaring **no**
`fieldGroups` | ✅ reported |
| `ctl_seeded.ctl_not_seed_key` | `hidden` + `readonly`, **no upsert
matches on it** | ✅ reported |
| `ctl_seeded.ctl_seed_key` | byte-identical to the row above, **matched
by a seed `externalId`** | silent — correctly |

The last two are a **matched pair** on the same object: identical
declarations, one variable between them. Only the one an upsert matches
on goes quiet. `hidden` is exempt nowhere.

## Tests, gates

- `pnpm --filter @objectstack/lint test` — **103 files, 3831 passed, 5
skipped**, including 11 new pins on both paths and on each boundary that
keeps the rule judging (undeclared group, flat bucket, hidden field, an
object with no groups, array-shaped field maps, composite `externalId`,
mapping `upsertKey`, the matched pair).
- `pnpm --filter @objectstack/lint typecheck` — exit 0.
- `pnpm --filter '@objectstack/lint^...' build` — exit 0 (dependency
closure).
- Changeset: `patch` on `@objectstack/lint`. Measured, not assumed — the
new message text is present in all four published bundles named by
`files[]` (`dist/index.js`, `dist/index.cjs`, `dist/runtime.js`,
`dist/runtime.cjs`) alongside a pre-existing-text positive control.

## Acceptance notes

Observed while measuring, **not filed** — no PR or author is heading for
either file:

- `SeedSchema` aliases `key` / `externalkey` / `naturalkey` onto
`externalId`, and `MappingSchema` aliases `key` / `matchOn` /
`externalId` / `externalIdField` onto `upsertKey`. On a **pre-parse**
stack an author using an alias gets no credit from path 2. `key` in
particular is in `LITERAL_KEYS` and is skipped outright, for good
reasons unrelated to this rule. Post-parse — which is what `os lint`
feeds — aliases are already normalized, so the live surface is covered.
- `SeedSchema.externalId` defaults to `'name'`. On a post-parse stack
every seeded object therefore credits its `name` column, which is
usually already exempt as the ADR-0079 title field and is in any case
genuinely matched by the loader.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk

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

---------

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… record what the kernel actually reads (objectstack-ai#18415)

`Part of objectstack-ai#18188` — the PM decides closure.

## 1. The load-bearing reading: does the kernel resolve a manifest
`dependencies` entry at plugin load?

**No. The kernel never parses the version range.** Measured two ways,
call site first (read-only, as the card's acceptance prefers), then
executed.

### 1a. Call sites — every consumer of `manifest.dependencies` in this
repo

`ManifestSchema.dependencies`
(`packages/spec/src/kernel/manifest.zod.ts:439`) is
`z.record(z.string(), z.string())` — a map of package id to version
range, where the value is typed only as "some string".

It has **two** consumers outside tests, and **both read
`Object.keys(...)` only**:

| consumer | line | what it takes |
| --- | --- | --- |
| `resolveArtifactPackageOrder` —
`packages/core/src/artifact-packages.ts` | 282 |
`Object.keys(manifest.dependencies ?? {})`, handed to
`resolvePluginOrder` as that node's `optionalDependencies` |
| `resolveWritePackageScope` —
`packages/metadata-protocol/src/protocol.ts` | 5346 |
`Object.keys(declared)`, walked transitively for the write-scope closure
|

Neither ever reads a value. `artifact-packages.ts` says so in its own
header (lines 105–124): "Why declared dependencies enter as
`optionalDependencies`" — an id that names a package inside the artifact
is a real edge, one that does not is simply not an edge here, "hoisted
ahead when composed, silently skipped when absent".

The machinery the page's *Version Constraints* section describes —
`SemanticVersionManager.satisfies()` and
`DependencyResolver.detectConflicts()` in
`packages/core/src/dependency-resolver.ts` — **has no production call
site at all**:

```
SUBJECT       grep -rn 'dependency-resolver\.js' --include=*.ts packages/ apps/
              -> packages/core/src/index.ts:126          (barrel re-export)
              -> packages/core/src/dependency-resolver.test.ts:2   (its own unit test)
              = 0 production importers

FIRING CTRL   grep -rn 'plugin-order\.js'      (same directory, same barrel, same export form)
              -> index.ts:12, kernel-base.ts:11, artifact-packages.ts:132, kernel.ts:15, plugin-registration.ts:41
              = 4 production importers  -> the instrument fires

DARK CTRL     grep -rn 'dependency-resolver-NOSUCH\.js'
              = 0                                        -> the instrument is not just printing hits
```

The kernel's own load-order surface is a *different* field:
`OrderablePlugin.dependencies` (`packages/core/src/plugin-order.ts:50`)
is `string[]` — plugin names, no version ranges in it at all.

### 1b. Executed, with a firing control on the value and one on the key

Ran against the built `@objectstack/core`
(`packages/core/dist/index.js`), calling `resolveArtifactPackageOrder`
directly:

```
S1  caret ^2.0.0  on @objectstack/core            -> OK   [com.acme.a]
S2  caret ^17.0.0 on @objectstack/core            -> OK   [com.acme.a]
C-VALUE  'NOT-A-VERSION-RANGE-@@@' on the same key -> OK   [com.acme.a]
C-KEY    the SAME garbage value, but the key names
         a sibling package IN the artifact         -> OK   [com.acme.b, com.acme.a]   (declared order was a, b)
C-DARK   no `dependencies` at all                  -> OK   [com.acme.a, com.acme.b]   (declared order preserved)
```

- **C-VALUE** is the firing control on the value: a string that is not a
version range in any grammar resolves exactly like `^2.0.0` does.
Nothing parses the value.
- **C-KEY** is the firing control on the key, and it discriminates: the
same unparseable value on a key that *is* in the artifact flips the
order from the declared `[a, b]` to `[b, a]`. So the probe can observe
`dependencies` having an effect — it has one, through its **keys**, and
none through its **values**.
- **C-DARK** confirms the flip in C-KEY came from the dependency edge
and not from something else in the call.

Also already pinned in the existing suite:
`packages/objectql/src/artifact-load-path.test.ts:235` — *"leaves a
dependency on a package OUTSIDE the artifact to the installer"* — which
declares `'@steedos/plugin-auth': '^2.0.0'` and asserts the artifact
resolves. Run: `pnpm --filter @objectstack/objectql exec vitest run
--maxWorkers=2 src/artifact-load-path.test.ts` :: **exit 0**, 14/14
passed.

### 1c. What that means for the card's grading

⇒ **This is an inert wrong number, not a load-time failure.** Copying
the example does not fail at plugin load. It is a documentation-accuracy
defect, not class (a). The card's own text says exactly this branch
belongs "in a PR's acceptance notes rather than in a card" — that
judgment is the PM's, not mine; the reading is recorded here either way.

## 2. Every number changed, and the reading behind it

⚠️ Because the kernel accepts **any** string here, the kernel reading
cannot justify a *particular* number. The justifying reading for the
numbers is the **npm registry plus this checkout**, and the page now
says which of the two governs.

| site | before | after | reading |
| --- | --- | --- | --- |
| `:60` manifest example 1, `dependencies` | `'^2.0.0'` | `'^17.0.0'` |
`packages/core/package.json` version = **17.4.0**; npm `latest` for
`@objectstack/core` = **17.4.0** (157 versions published). `^17.0.0` is
the spelling PR objectstack-ai#18186 already put on the npm half of this same page at
`:703`, so both halves now agree. |
| `:436` *Dependency Types* §1, `dependencies` | `'^2.0.0'` |
`'^17.0.0'` | same |
| `:942` the pin-vs-caret worked example | `'2.0.0', // Not '^2.0.0'` |
`'17.4.0', // Not '^17.0.0'` | same; see §3 |

Registry control: the same two-legged query returned a full packument
for `@objectstack/core` (firing) and `Not found` for `@objectstack/ui`
(see §4) — the probe discriminates.

## 3. What I decided about line 942, and why

**Changed the number, kept the argument, and added one paragraph so the
reason is one the runtime actually delivers.**

The section is *Best Practices → 3. Pin Core Dependencies*. Its argument
is **exact pin beats caret range**; the number is the vehicle, not the
point. Changing `'2.0.0'` to `'17.4.0'` and the comment `// Not
'^2.0.0'` to `// Not '^17.0.0'` preserves that argument *form for form*
— an exact version on the left, the caret form named in the comment as
the thing not to write — while removing the fifteen-major-stale number a
reader would copy. Acceptance criterion 3 holds: the prose still argues
"pin exactly", and the demonstration still demonstrates an exact pin
against a caret.

But the old reason — "to avoid surprises" — is one §1 shows the kernel
does not deliver: the range is never checked at load, so pinning it
prevents no surprise *there*. Leaving that sentence untouched would have
left the page advertising an enforcement that does not exist (AGENTS.md
Prime Directive objectstack-ai#10's corollary). So the reason now points where it is
true: the installer, and the same dependency in `package.json`, which
npm really does resolve.

The added paragraph and the new `Callout` under *Version Constraints*
are the same idiom the page already uses five times over
(`optionalDependencies` / `peerDependencies` are "proposal-only"; there
is no `definePlugin()`; no `onBoot`, `onUpgrade` or `onUninstall` hook).
Nothing was invented for this PR.

## 4. The `@objectstack/ui` sites at `:71` and `:462` — **unchanged**,
and here is what I found

Both sit in **`peerDependencies`**, which this page's own callout at
`:425-429` already declares proposal-only: "the schema declares neither,
so nothing resolves them."

Two readings on the package itself:

- **Not in this checkout.** No `package.json` under `packages/`, `apps/`
declares the name `@objectstack/ui`. Firing control: the identical query
for `@objectstack/core` returns `packages/core/package.json`. Dark
control: a nonsense name returns 0.
- **Not on npm.** `GET https://registry.npmjs.org/@objectstack%2fui` ->
`{"error":"Not found"}`. Firing control on the same endpoint:
`@objectstack/core` -> `latest: 17.4.0`, 157 versions.

So `^2.0.0` for `@objectstack/ui` is a range on a package that exists
**neither here nor on the public registry**, in a block the page itself
says nothing resolves. There is no reading that says what the right
number would be, so per the dispatch fence I changed nothing and report
it instead. It is a live question for whoever owns that name.

## 5. Changeset: `skip-changeset`, measured against `files[]`

Diff is one file: `content/docs/protocol/kernel/plugin-spec.mdx`.

```
package.json scanned:                                  77
non-private (publishable):                             70
publishable packages with NO files[] field:             0   (so npm's ship-everything default is nowhere in play)
SUBJECT   files[] arrays mentioning "content":          0
FIRING CONTROL  files[] arrays mentioning "dist":      70   (70/70 -> the reader is reading the arrays)
```

`content/docs/**` is rendered by `apps/docs`, whose `package.json` is
`"private": true` and declares no `files[]` — it publishes nothing.
**Zero published bytes move.** Asserted from the `files[]` readings
above, not from the path name.

## 6. Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack content/docs/protocol/kernel/plugin-spec.mdx`
from this worktree. **39 derived, 39 run, 0 NOT-MEASURED, 0 UNRUN**,
reconciled with `--ran` carrying every exit code:

```
✓ dispatch-gates --ran: 39 derived famil(ies) accounted for — 39 run,
  0 NOT-MEASURED (a DERIVED zero — all 39 recorded an exit code and none of them is 3).
```

**38 of 39 exit 0.** Repo-wide `pnpm lint` (`eslint .
--no-inline-config`, no narrowing) :: **exit 0** at `7cbd43373`, whose
tree `bf6b9c0f5c708647871a55046be254192c1a8b8d` is byte-identical to
this PR's head `d4f2919c2` (the amend was message-only).

### The one red, and why it is not this diff

`pnpm check:cross-package-test-inputs` :: **exit 1**, flagging
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
into `packages/spec/dist/`. Neither path is in this diff. Proven by
ablation, not by argument:

1. `git checkout 7358c1c --
content/docs/protocol/kernel/plugin-spec.mdx` — ablation verified on
disk: the added marker went to **0** occurrences and the base
`'@objectstack/core': '^2.0.0',` came back to **2**.
2. Re-ran the gate with the diff removed :: **exit 1**, same `FAIL:`
line. The red survives the ablation ⇒ it is not caused by this change.
3. Restored with `git checkout HEAD -- …`, verified two ways: `git diff
HEAD` empty for that path, and `git hash-object` =
`f948028b39a1dd0e1f3959bf1c1c628674f3c8ae` = the HEAD blob.

CI corroborates: **"Lint & Repo Gates" completed `success`** on
`7358c1c5b`, this branch's merge base. The red is a property of a
locally-built tree (the gate walks `packages/spec/dist/`, which only
exists here because I built it for the §1b probe), not of `main`.

`pnpm --filter @objectstack/spec run check:skill-examples` first exited
1 with a **PREREQUISITE NOT MET** refusal — `packages/client-react/dist`
held no declarations. That is not a verdict. After `pnpm --filter
'@objectstack/client-react...' build` it exits **0**: 258 prose examples
type-check across 3 surfaces.

## Acceptance notes (⛔ not filed, noted here)

- **`content/docs/protocol/kernel/plugin-spec.mdx:470-471`** still
introduces the version-constraint grammar through
`SemanticVersionManager.satisfies()`, a function with no production call
site. The new callout directly above it now says so, so the page no
longer misleads — but the sentence itself is a candidate for a rewrite
that stops leading with a dead symbol. Successor: whoever next edits
this page's *Dependency Management* section.
- **`pnpm check:cross-package-test-inputs` reds on any tree where
`packages/spec/dist/` is built.** Green on CI at the same commit, so
this is a local-run-only asymmetry, not a defect in the code. Successor:
any developer who builds `packages/spec` and then runs the derived gate
list locally — which is every dev on a docs card like this one.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk

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

Co-authored-by: Claude <[email protected]>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…e first of objectstack-ai#18582's three debts (objectstack-ai#18587)

Part of objectstack-ai#18582

Clause-②: no

Seeds `packages/spec/liveness/sharing_rule.json` and moves
`sharing_rule` out of `PENDING_GOVERNANCE` into `GOVERNED`. This is
**one of the three debts** on that card; `connector` and
`analytics_cube` stay on it, which is why the first line is `Part of`
and not a closing keyword.

## What the coverage line says, before and after

PR objectstack-ai#18581 made this line print on **every** run precisely so a change
like this is visible. Measured on this branch:

```
before (origin/main @ bb9794a)
  governance denominator: 30 authorable type(s) — 26 registered kind(s) + 4 unregistered-kind
  stack collection(s) (analytics_cube, connector, sharing_rule, webhook); 27 governed,
  3 awaiting a ledger (analytics_cube, connector, sharing_rule) — a worklist, not a merge gate.
    (+ 9 type(s) governed from OUTSIDE the denominator via SPEC_ONLY_SCHEMAS … 36 governed in total.)

after (this branch)
  governance denominator: 30 authorable type(s) — 26 registered kind(s) + 4 unregistered-kind
  stack collection(s) (analytics_cube, connector, sharing_rule, webhook); 28 governed,
  2 awaiting a ledger (analytics_cube, connector) — a worklist, not a merge gate.
    (+ 9 type(s) governed from OUTSIDE the denominator via SPEC_ONLY_SCHEMAS … 37 governed in total.)
```

The other counters move the way a seeded type should move them, and one
of them deliberately does not:

| counter | before | after |
|---|---|---|
| classified properties | (no `sharing_rule` row) | `sharing_rule 17
classified (live 16, planned 1)` |
| repo-local evidence paths resolved | 505 / 505 | 524 / 524 |
| `path#symbol` anchors resolved | 583 / 583 | 617 / 617 |
| key-mention pairs anchored | 504 of 505 asked, 1 exempt | 523 of 524
asked, 1 exempt |
| live entries citing a PRODUCER | 39 | 48 |
| **container coverage (undrilled child keys)** | **113 containers / 578
keys** | **113 containers / 578 keys** |

The last row is the point of drilling `sharedWith`: the new type adds
**zero** rows to `undrilled-containers.baseline.json`.

## The classification, and the one row that is not `live`

Ten authored keys (plus the seven ADR-0010 envelope fields the gate
auto-classifies). Nine are `live`; `type` is `planned`.

**Every row carries a `producer`, and that is the substance of the
work.** The authoring shape is not the enforced shape: ADR-0057 D6 makes
the `sys_sharing_rule` row canonical and `bootstrapDeclaredSharingRules`
translates each authored key into it at boot — nothing re-parses
`SharingRuleSchema` at enforcement time. So a consumer pointer alone
would prove only that a **column** is read, never that the authored
value reaches it. That is the `seed.env` shape (objectstack-ai#4837) applied to a
whole type rather than to one key.

**`type` is `planned`, deliberately neither `live` nor `dead`.**
`SharingRuleType` has exactly one member and `CriteriaSharingRuleSchema`
pins the key as `z.literal('criteria')`, so every value an author can
write is the same value. Its only reader in this repo is a defensive `if
(r.type === 'owner')` in the seeder, unreachable for anything the schema
admits (`owner` rules left the authoring surface with ADR-0078).

- not `live` — nothing dispatches on it; a comparison against a value
the schema rejects is Prime Directive objectstack-ai#10's `case` label with no
reachable call site;
- not `dead` — the key is **required**, so it is not a silent no-op an
author can get wrong, and the schema header records the intent (kept as
the discriminant so a future enforced rule type re-joins as a union
member). Marking it `dead` would put a required literal on the ADR-0049
enforce-or-remove worklist, where removing it breaks every authored rule
to delete nothing.
- precedent: `action.operation`, the same shape — a one-member
discriminator held `planned` until a runtime half dispatched on it, then
flipped to `live` (objectstack-ai#15080).

The negative rests on a census with a lit control, not a bare grep: the
population that reads a declared rule **item** is the seeder, four
`packages/lint` validators and objectui's create-door client validation;
searching that population for `r.type` / `rule.type` returns the one
defensive comparison and nothing else, while the same search for
`r.condition` / `rule.condition` returns hits in three of them.

**Preview read points enumerated, per the objectstack-ai#7131 mechanical rule** —
`registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types
and `sharing_rule` is **not** one of them. Recorded in the ledger rather
than skipped, because "the type has no registered preview" is the
sentence a later sweep needs. What objectui does consume is the whole
shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES`).

**One decoy named so the next census does not trip on it:** objectui's
own `SharingRuleConfig` (`packages/types/src/permissions.ts`) is a
different shape entirely, re-exported twice and read by nothing. It
matches this type by name only.

## File surface — three deviations, all mechanically forced, none
discretionary

The dispatch scoped this to the ledger, the two `check-liveness.mts`
rows and a changeset, with `liveness/README.md` **read-only**. Three
files outside that surface had to move, because the gates read them off
`GOVERNED` and off the ledger set:

1. **`packages/spec/liveness/README.md`** — `reconcileReadmeTable` fails
on a `GOVERNED` type with no row in the "Current state" table, and the
heading's `N governed types` is checked three ways against the rows and
against `GOVERNED.length`. A `check:liveness` that exits 0 and a
read-only README are not both reachable. Row written by measurement;
heading 36 → 37; the tail paragraph that described the map as holding
three debts now says which one is paid.
2. **`packages/spec/liveness/state-counts.md`** — generated,
`merge=os-regen`, proved fresh by the same gate. Regenerated with `pnpm
--filter @objectstack/spec gen:liveness-counts`, never hand-edited.
3. **`docs/qa/platform-checklist/coverage.json`** — this ratchet
**derives its universe from `packages/spec/liveness/*.json`**, so
seeding a ledger is exactly what makes the kind `UNCLASSIFIED` there
(`check:platform-checklist` exit 1, reproduced before and after). Mapped
to the three items that already exercise the surface —
`access-security.sharing-rules-widen`,
`access-security.sharing-rule-authoring-ui`,
`access-security.record-share-grant-revoke` — rather than waived. **No
checklist item is authored here**, and a waiver would have been false.

A changeset **is** owed and is not `skip-changeset`: `packages/spec`'s
`files[]` includes `liveness`, so the new ledger ships inside the
tarball. `patch`.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands`, run with each exit code landed
on disk before being read, then reconciled with `--ran`:

```
dispatch-gates --ran: 65 derived famil(ies) accounted for — 63 run, 2 NOT-MEASURED
  (2 DERIVED from a recorded exit 3), 0 UNRUN.
```

- **`check:liveness` exits 0**, with the coverage line above.
- `check:generated` — **exit 0 on a built tree** (15 of 15 artifacts
current). It exits 1 on an unbuilt one and says so itself;
`packages/spec` was built under the shared verify lock before the
verdict was read.
- `check:platform-checklist` — exit 1 before the coverage entry, exit 0
after: `36 kinds mapped, 1 waived`.
- `check:pm-dispatch-gates` exit 0 (1746 self-test cases; the battery
takes ~520s on this box).
- `packages/spec` — `typecheck` exit 0, `test` exit 0 (483 files / 13780
tests). The liveness script suites the edited gate owns, plus every test
whose text names `check-liveness`, run as their own slice: 13 files /
378 tests, exit 0.
- **NOT MEASURED (2)** — `check:dual-build-cjs-loads` and
`check:lean-entry-closure`, both `exit 3 · PREREQUISITE NOT MET`: they
read a whole-repo `dist/` this container has not built. Neither pass nor
finding. CI builds the closure.
- **Known and already filed, not a finding here** —
`check:cross-package-test-inputs` exits 1 once `packages/spec` has been
built and 0 when it has not (objectstack-ai#18353 / objectstack-ai#18440). Recorded in both states;
its message names `packages/spec/dist/` and
`packages/cli/test/init-created-files-summary.e2e.test.ts`, neither of
which this diff touches.
- **`pnpm lint` is CI's whole-repo run.** The narrowing here is a
measurement, not a skip: eslint's own `files` declaration is
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, so five of the six changed files
(`.md`, `.json`) are outside its universe by that declaration; the
sixth, `packages/spec/scripts/liveness/check-liveness.mts`, linted clean
— `--format json` reports **1 file, 0 errors, 0 warnings**.
`eslint.config.mjs` states in its own prose that this repo "never
enables type-aware linting (no `parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file", so nothing in this diff can
move the verdict on a file it does not touch. Gate numbers are quoted
from runs at `2f932b09`.

## Acceptance notes

Noted, not filed, and one to file:

- **To file** — the four sharing-related entries in
`packages/spec/scripts/liveness/proof-registry.mts` record a
`blockedReason` whose premise this PR falsifies: `bu-hierarchy-sharing`,
`sharing-rule-criteria-required`, `declarative-rbac-seeding` and
`sharing-rule-org-scoped-listing` each say sharing rules are "authored
at STACK level, which is not a governed metadata type … so there is no
ledger entry to ratchet". There is one now.
`showcase-declarative-rbac-seeding` in particular authors
`sharingRules[]` on the showcase stack and asserts the seeded row's
`object_name`, `recipient_type`, `recipient_id` and translated
`criteria_json` — i.e. it exercises five of these keys end to end and is
a genuine ADR-0054 binding candidate. ⛔ Deliberately **not** done here:
binding a high-risk class is a separate ADR-0054 §3 act, one class at a
time, and `sharing-rule-criteria-required` is the counter-example that
makes it a judgement rather than a sweep — it POSTs the runtime body to
`/sharing/rules` and never authors the spec key, so it must **not** bind
`condition`.
- Noted, not filed — `packages/spec/liveness/README.md`'s "Adding a
type" recipe lists four steps and none of them is the README row, the
heading count, the generated `state-counts.md` or the platform-checklist
coverage entry, all four of which the gates now require. The recipe
predates those three checks. Whoever seeds `connector` or
`analytics_cube` next will walk into the same three red gates this PR
did; that seat is the one that will carry it.

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

---------

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant