Skip to content

fix(spec): the liveness governance denominator is the authorable set, not the registered set - #18581

Merged
os-bill merged 3 commits into
mainfrom
claude/issue-18133-liveness-governance-denominator
Sep 17, 2026
Merged

os-bill merged 3 commits into
mainfrom
claude/issue-18133-liveness-governance-denominator

Conversation

@os-bill

@os-bill os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18133

Clause-②: no

This card tightens governance coverage: the denominator the liveness ratchet divides by grows, three previously-unreachable types become nameable, and no accept set, public export or schema moves. The registry itself is byte-untouched.

The defect, measured first-hand against origin/main

check-liveness.mts built report.ungoverned from listMetadataTypeSchemaTypes() under a comment stating that function returns "exactly the set of authorable metadata types". That sentence is the declared contract, and it is false — the same sentence #17356 measured false for the reachability gate, one gate over.

reading (probe run at base 879b51270) result
listMetadataTypeSchemaTypes() 26 names; analytics_cube / connector / sharing_rule / webhook absent from all four
listUnregisteredKindSchemaTypes() exactly those four
firing control on the same probe view and flow present in the registered set; view absent from the unregistered set
getMetadataTypeSchema(t) for each of the four resolves a schema (third fallback, #6245)
GOVERNED / PENDING_GOVERNANCE membership webhook governed; the other three in neither map; PENDING_GOVERNANCE was {}
packages/spec/liveness/ no connector.json, no sharing_rule.json, no analytics_cube.json

⇒ a type in neither map produces no row in any of this gate's lists, so ungoverned: [] read identically whether the gate had looked and found nothing or had never looked at all. That indistinguishability is the finding; the count of what it hid is deliberately not claimed.

One card correction. The card attributes webhook's manual patch to an EXTRA_SCHEMAS row. That identifier does not exist anywhere in this repository — the mechanism is SPEC_ONLY_SCHEMAS in check-liveness.mts, plus liveness/webhook.json. The claim is right, the symbol name is not.

Why the repair is local, on the merits

The dispatch flagged a possible fork into packages/spec/src/kernel/metadata-type-schemas.ts. It is not one, and the target file says so itself. listUnregisteredKindSchemaTypes() already exists there (#6931) and its own docblock declares:

[#6931] This exists so a check can ENUMERATE that map, and for nothing else.
⚠️ Being listed by this function grants NOTHING. It returns names, not schemas, not descriptors: no MetadataTypeSchema enum membership, no DEFAULT_METADATA_TYPE_REGISTRY entry, no create seed, no authorization verdict, no place in the #4001 campaign count.

And the precedent is already landed, in the second consumer the dispatch warned about: reachabilityRootTypes() in scripts/build-schemas.ts (#17356, PR #18131) is this exact union, computed inside the consuming gate, whose docblock states that listMetadataTypeSchemaTypes() "answers its own question correctly and this file does not touch it."

#6245's guarantee survives byte-for-byte: metadata-type-schemas.ts is not in this diff. The gate that was asking the wrong question is where the question is fixed.

Not yet one shared spelling. The card suggests a single helper read by both gates. scripts/build-schemas.ts is held by seat 1 (#18301 / PR #18529), so folding the two together is a follow-up; until then each docblock cross-references the other by name.

What the gate now prints that it did not before

Green run, before → after:

before:  (no coverage line at all — it printed only when PENDING_GOVERNANCE was non-empty)
after:   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 —
           not metadata types, so the override IS their governance; 36 governed in total.)

The success sentence changed too: "every registered type is governed or explicitly pending" is now "every authorable type — registered kind or unregistered-kind stack collection — is governed or explicitly pending".

Does it turn any existing check red? No, and here is the arithmetic. The three newly visible types are recorded as declared debts in PENDING_GOVERNANCE with a reason and an issue number apiece — the disposition the gate's own failure text prescribes, and the one that keeps them stated rather than silent. pnpm --filter @objectstack/spec check:liveness exits 0 on this branch, and state-counts.md and the README state table are both still current (36 rows, unchanged — the ledger set did not move). Leaving them in report.ungoverned instead would exit 1 on every PR in the repo, which is a broken main, not a delivered finding.

Reverse verification — two legs, both fired, both restored

Run against the committed implementation; each leg proved its mutation reached disk by anchor count before the gate ran, and each restored via git checkout HEAD -- PATH under a trap ... EXIT INT TERM, verified by blob hash rather than by an exit code.

Leg A — the new denominator really reaches an unregistered kind. Removed the connector row from PENDING_GOVERNANCE (anchor count 1 → 0):

✗ 1 AUTHORABLE metadata type(s) governed by nothing:
    connector

exit 1. Under the old denominator no edit to that map could have produced this row for connector — it would have been reported stale instead, because the type was not in the set at all.

Leg C — the union line is load-bearing, not decorative. Reverted authorableTypes() to the registered set alone, keeping the three rows (anchor count 1 → 0, injected marker count 1):

✗ 3 stale PENDING_GOVERNANCE row(s) — the debt is already paid:
    analytics_cube
    connector
    sharing_rule

exit 1. That output is precisely the old code's whole vocabulary for these three: "not a type I know", never "a type nobody governs".

Restore proof for both legs: on-disk blob b3c8aded6e38ce73bd8097dba62554da19ec97ff equals HEAD:packages/spec/scripts/liveness/check-liveness.mts, and git diff HEAD --stat for that path is empty. No permanent test file was left behind by either leg.

Tests

scripts/liveness/check-liveness.test.ts gains a #18133 block that holds the gate's output answerable to the live registry rather than to a copied list — a hard-coded expectation would pass unchanged if the gate stopped reading the registry at all, which is the regression class this is for.

  • a control that both sides of the union are non-empty and disjoint (without it, "the denominator omits nothing" is satisfied by a registry that enumerates nothing);
  • the denominator contains every listUnregisteredKindSchemaTypes() name, is strictly larger than the registered set, and equals the union exactly — this is the assertion that goes red the moment somebody simplifies the union away;
  • every member is governed or explicitly pending, ungoverned and stalePending both empty;
  • the composition line is printed on a green run;
  • meta: bind Zod schemas for webhook / connector / sharing_rule WITHOUT registering the kinds — close the unvalidated PUT /meta write hole (#2657 audit, option A) #6245's guarantee asserted from the gate that had the motive to break it: the unregistered kinds are still absent from listMetadataTypeSchemaTypes().

Verification

Final commit 7c98551bac; every reading below is from that tree.

  • pnpm --filter @objectstack/spec exec vitest run scripts/liveness/check-liveness.test.ts — 58 passed (was 53).
  • pnpm --filter @objectstack/spec test482 files passed, 1 skipped; 13776 tests passed, 1 skipped.
  • pnpm --filter @objectstack/spec typecheck — exit 0. Both edited files are proven in a tsc program: tsc -p tsconfig.scripts.json --listFiles names scripts/liveness/check-liveness.mts and scripts/liveness/check-liveness.test.ts.
  • pnpm --filter @objectstack/spec check:liveness — exit 0. check:empty-state — exit 0.
  • Gate families: derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands off the merge base (never a hand-fed path list), reconciled with --ran: 68 derived, 63 run green, 5 NOT MEASURED, 0 UNRUN. Every exit code was landed to disk before being read.
  • The 5 NOT MEASURED all exit 3PREREQUISITE NOT MET, each refusing because no package has a dist/: check:dts-closure, check:dual-build-cjs-loads, check:lean-entry-closure, check:sourcemap-no-sources-content, check:type-check-debt. They need a repo-wide build; this diff contains no built-output source, and CI covers them. ⛔ Neither pass nor finding.
  • pnpm check:cross-package-test-inputs exits 0 here — consistent with the known packages/spec-is-built behaviour already filed as [finding] check:cross-package-test-inputs passes in CI and fails on a built tree — its verdict is a function of gitignored build state #18353 / [finding] check:cross-package-test-inputs answers 1 or 0 depending on whether packages/spec has been BUILT — the author who follows AGENTS.md is the only one who sees the red, and CI never does #18440; nothing new is filed for it.
  • Lint, narrowed and the narrowing proven. eslint --no-inline-config --format json over the two changed TS files: 2 files linted, 0 errors, 0 warnings (count read from the JSON, not from prose). Population read from eslint.config.mjs itself: the global block is files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED, so the two .md paths in this diff are outside eslint's population entirely. Invariance: that config declares in its own words that this repo "runs one eslint.config.mjs, which 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. The repo-wide sweep is CI's.
  • Control-character scan beyond check:nul-bytes: grep -naP over all four changed files — no matches.

Declared deviation — one file outside the dispatched surface

The dispatched surface was check-liveness.mts · its test · any liveness/*.json made owed · .changeset/*.md. This diff also edits packages/spec/liveness/README.md, and that is a deliberate, declared addition rather than an oversight:

  1. that README carries a second copy of the same false sentence — "i.e. exactly the set of authorable metadata types" — so repairing only the script would have left the card's own warning realised: the sentence reappearing at a third door;
  2. more decisively, this change makes an existing published sentence there factually false: "PENDING_GOVERNANCE in check-liveness.mts is empty". Shipping a diff that falsifies shipped documentation is the shape this ledger's own history (spec(liveness): the README state table has no rows for api / capability — the table silently fell behind its own registry #7257) exists to stop — a completeness sentence no build can fail.

packages/spec's files[] ships liveness/, so that README is published — which is also why this PR carries a patch changeset rather than skip-changeset. Nothing under scripts/ ships; had the README not been owed, no published byte would have moved. Both edits are prose only: the state table, its heading count (36) and state-counts.md are untouched, and check:liveness reconciles all three green.

Acceptance notes

Observed while measuring, not filed and not fixed here:

🤖 Generated with Claude Code

https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/README.md), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/README.md) — pages documenting those are invisible to this run
  • 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 — 136 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 5ed7ad9df84a319a9842ffceb97c030406a508a3packageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@os-bill
os-bill marked this pull request as ready for review September 17, 2026 04:17
@os-bill
os-bill added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit bb9794a Sep 17, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-18133-liveness-governance-denominator branch September 17, 2026 04:42
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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant