Skip to content

Commit 3de535b

Browse files
os-zhuangzhuangjianguoclaude
authored
fix(spec,metadata): three self-descriptions re-anchored to a verifiable source of truth (#7024) (#7032)
Sweep card #7024 — every member is a self-description that stopped being answerable to reality, and each fix re-anchors it to something checkable. #6242 — eight enumerations of the stack-collection set, none answerable to `stack.zod.ts`. New `pnpm check:stack-collection-maps` derives the set from `ObjectStackDefinitionSchema` and reconciles all eight against it in both directions; every deviation is a waiver row carrying its reason, and a stale waiver fails like a stale ledger row. Writing it found a seventh site the hand-audit missed (`APP_CATEGORY_KEYS`) and a divergence between ObjectQL's two copies that neither list shows alone. One code change: `ARTIFACT_FIELD_TO_TYPE` no longer aims the seed collection (`data:`) at the analytics kind (`'dataset'`) — a provably inert entry pointed at the wrong kind. #4837 — the ledger counted "a consumer read site exists" as `live` evidence, which misses producer-less dead keys (`Seed.env`). Adds the `producer` field (resolved like `evidence`, so a call-site claim cannot rot), the criterion in liveness/README.md, and a `--producer-gap` worklist. Six entries re-verified by hand; `seed.env` restamped with both sides cited. #4895 — adds `evidenceScope` (`in-repo` | `cross-repo`), and re-verifies the #4667 retirement batch cross-repo against objectui @c2fd1223: all six keys confirmed, no counter-example. The two surviving tombstone rows carry the scope and the pinned commit. ⛔ Zero acceptance-face changes: authorable-surface.base.json is untouched, and the three drift rows that sit on acceptance faces are recorded as waivers rather than fixed. Claude-Session: https://claude.ai/code/session_01Y3ydyJV9reKBgoez8LAoqY Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 73648ba commit 3de535b

15 files changed

Lines changed: 1449 additions & 13 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
feat(spec): a liveness `live` verdict can now cite its PRODUCER, and an entry can declare how wide its last look was (#4837, #4895)
6+
7+
Two fields on liveness ledger entries, both optional, both validated, each
8+
closing a way a `live` verdict has been measurably wrong.
9+
10+
**`producer` — a consumer is only half the call graph (#4837).** `seed.json`
11+
marked `Seed.env` **live**, evidence `seed-loader.ts:91`, note *"filterByEnv
12+
drops datasets whose env list excludes the running environment."* Every word was
13+
checkable against the file and the verdict was still false: line 91 really did
14+
call `filterByEnv(request.seeds, config.env)`, but none of the **six** call sites
15+
that build a `SeedLoaderRequest` passed `env` — so `config.env` was permanently
16+
`undefined`, the filter returned its input on its first line, and `dataset.env`
17+
was never read at all. The evidence pointed at the consumer; the property was
18+
dead at the producer. `seed-loader.test.ts` passed throughout, because the test
19+
supplies `config.env` itself: it exercised a mechanism nothing fed.
20+
21+
So the criterion, now written in `liveness/README.md`: when a property's runtime
22+
effect depends on a **second input somebody must supply**, `live` requires
23+
evidence on the producer side too. `producer` carries it, and resolves through
24+
the same resolver as `evidence` — a repo-local path that does not exist fails
25+
CI, because a call-site claim nothing can falsify is exactly what the field
26+
exists to remove. Absence never fails (the ledger predates the field);
27+
`check:liveness --producer-gap` prints the worklist, and the README's table says
28+
which shapes actually need one — the risk class is optional config with a
29+
default, which always "has a value" in the type system and can still be
30+
`undefined` at runtime.
31+
32+
**`evidenceScope` — how wide the last look was (#4895).** Four measured verdicts
33+
were reached by searching this repo alone and published as if they covered every
34+
consumer: `app.homePageId` ("no shell ever read it" — objectui's
35+
`resolveLandingRoute()` had been reading it all along), `flow.…position` (marked
36+
live on a designer that wrote its own `ui:{x,y}` — a false *live*, the opposite
37+
direction), `HttpMethod` (a scan matching only `import … from`), and
38+
`Notification` (objectui re-exported it with `export … from` and the real
39+
consumers imported from `@object-ui/types` — two hops, which no specifier match
40+
can see). `"evidenceScope": "in-repo" | "cross-repo"` records what was actually
41+
done. Absent is a worklist row; a value outside the vocabulary fails, the same
42+
asymmetry `verifiedAt` uses.
43+
44+
**Entries re-verified while landing this** (not a mass re-grade — six entries
45+
whose call graph was closed by hand):
46+
47+
- `seed.env` — the specimen. Evidence restamped to the live line and a `producer`
48+
added: since #4704, `load()` resolves the comparison environment itself at the
49+
one funnel every seeding path goes through, so call site seven cannot reopen
50+
the hole.
51+
- `job.timeout`, `hook.retryPolicy` / `timeout` / `onError` — the same
52+
"consumer reads it out of an options object built elsewhere" shape, checked
53+
and **holding**: the job scheduler threads `{ retryPolicy, timeout }` into
54+
`svc.schedule`, and the hook binder hands the authored hook straight to
55+
`wrapDeclarativeHook`. Cited, not assumed.
56+
- `app.homePageId` and `book.groups[].translations` — the two surviving
57+
tombstones from the #4667 retirement batch, re-verified **cross-repo** against
58+
objectui `@c2fd1223` and confirmed. objectui now rejects `homePageId` in its
59+
own schema with a pin test, and its book-spine interfaces declare no
60+
`translations`. (The other four keys in that batch were strict-removed, so
61+
they have no ledger row to date; their re-verification is recorded in the PR.)
62+
63+
`cloud` is not reachable from an open-source checkout, so `cross-repo` means "the
64+
realms named in the evidence", never "everywhere" — the README says so where the
65+
value is defined.
66+
67+
`producer.mts` is pure and unit-tested for the reason `orphans.mts` and
68+
`drill.mts` are: on the shipped ledgers these checks are almost entirely quiet,
69+
so a green gate proves nothing about whether they can fire.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata,repo): every enumeration of the stack-collection set is now answerable to `stack.zod.ts`, and the artifact map stops aiming `data:` at the analytics kind (#6242)
6+
7+
`ObjectStackDefinitionSchema` decides which collections a stack may declare — 32
8+
of them today. **Seven** other places re-enumerate that same set by hand (eight
9+
enumerations in all, because ObjectQL declares its list twice), and nothing
10+
compared any of them to the schema or to each other:
11+
12+
| Enumeration | Site |
13+
|---|---|
14+
| `MAP_SUPPORTED_FIELDS` / `PLURAL_TO_SINGULAR` | `packages/spec/src/shared/metadata-collection.zod.ts` |
15+
| `MetadataCategoryEnum` | `packages/spec/src/kernel/package-artifact.zod.ts` |
16+
| `metadataArrayKeys` ×2 | `packages/objectql/src/engine.ts` |
17+
| `ARTIFACT_FIELD_TO_TYPE` | `packages/metadata/src/plugin.ts` |
18+
| `APP_CATEGORY_KEYS` | `packages/runtime/src/app-plugin.ts` |
19+
| `STACK_COLLECTION_COVERAGE` | `examples/app-showcase/src/coverage.ts` |
20+
21+
They had drifted independently: `ragPipelines` mapped in three of them though no
22+
schema declares it; `workflows` / `approvals` / `roles` / `profiles` / `policies`
23+
still iterated by both ObjectQL loops after ADR-0019 / ADR-0020 / ADR-0088 /
24+
ADR-0090 retired them; `triggers` + `workflows` still legal artifact categories;
25+
19 of 32 collections absent from that enum.
26+
27+
Every row looks like a one-line typo in isolation, and each **has** been fixed
28+
one line at a time before — `docs` in `ARTIFACT_FIELD_TO_TYPE`, `roles`
29+
`positions` in the same map, `capabilities` in `metadataArrayKeys` — each still
30+
carrying its "this key was missing and it silently dropped X" comment. The cause
31+
is structural: `KIND_COVERAGE` is answerable to the metadata-type registry and
32+
fails CI when a kind is added without an entry, and the liveness ledger is
33+
answerable to the same registry. The collection maps were answerable to nothing.
34+
35+
**The gate.** `pnpm check:stack-collection-maps` (root
36+
`scripts/check-stack-collection-maps.mjs`, wired into the lint job) derives the
37+
collection set from `ObjectStackDefinitionSchema` — top-level keys whose value is
38+
`z.array(<X>Schema)`, a mechanical rule rather than a second hand-kept list — and
39+
reconciles all eight enumerations against it in **both** directions. Deriving them
40+
is not possible today (they disagree on purpose as often as by accident: `views`
41+
has no `name`, `data` seeds key by `object`, `translations` is a record), so each
42+
deviation must instead be a waiver row **carrying its reason**, and the list is a
43+
ratchet: a waiver that no longer applies fails, like a stale ledger row. An
44+
enumeration whose symbol cannot be extracted fails too — an empty list would
45+
reconcile against everything.
46+
47+
Writing it immediately found a **seventh** site the hand-audit had missed
48+
(`APP_CATEGORY_KEYS`) and one divergence *between* the two ObjectQL copies that
49+
neither list shows alone: `jobs`, `emailTemplates`, `tools` and `skills` are
50+
registered from a manifest and **not** from a nested plugin, so a package
51+
shipping them from a nested plugin registers nothing and stamps no ADR-0010
52+
provenance. `capabilities` was added to that copy for exactly this reason
53+
(#5870); nobody then asked what else the two lists disagreed about. Recorded as
54+
a waiver with the measurement, not fixed here — closing it changes what a nested
55+
plugin registers at boot.
56+
57+
**The one code change**: `ARTIFACT_FIELD_TO_TYPE` no longer maps `data:` (the
58+
SEED collection) to `'dataset'` (the ADR-0021 analytics kind) — the exact name
59+
collision `metadata-plugin.zod.ts` warns about in prose. The entry was provably
60+
inert (`SeedSchema` declares no `name`, and the ingest loop skips nameless
61+
items), so nothing changes at runtime; what changes is that a dead pointer aimed
62+
at the wrong kind is gone, instead of waiting for either side to move. Not
63+
repointed at `'seed'`: seeds are applied by `SeedLoaderService` off the bundle,
64+
never registered as metadata items, so that would be new behaviour rather than a
65+
corrected name. The absence is now pinned by the gate.
66+
67+
Everything else the gate reports is recorded as a waiver with its reason and left
68+
alone, deliberately — three of the drift rows sit on **acceptance faces**
69+
(`MetadataCategoryEnum` decides what a published artifact may declare) and the
70+
rest are `engine-core` behaviour changes owing their own verification. The value
71+
landing today is that all eight enumerations now have a checked relationship to
72+
the schema rather than an assumed one.

.github/workflows/lint.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,18 @@ jobs:
133133
- name: Raw control-byte guard
134134
run: pnpm check:nul-bytes
135135

136+
# Stack-collection enumerations vs the schema (#6242). `stack.zod.ts`
137+
# decides which collections a stack may declare; eight other enumerations
138+
# of that same set are hand-maintained (the map-format list, the
139+
# plural→singular map, the artifact category enum, ObjectQL's two
140+
# registration loops, the artifact-ingest field map, the runtime's
141+
# app-payload probe, the showcase coverage manifest) and nothing compared
142+
# any of them to the schema. Each drift instance has been fixed one line at
143+
# a time before, always after a missing key silently dropped a collection.
144+
# A deviation is legal here only as a waiver row carrying its reason.
145+
- name: Stack-collection enumerations answerable to stack.zod.ts
146+
run: pnpm check:stack-collection-maps
147+
136148
# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
137149
# Markdown/MDX are not type-checked or ESLinted, so skills/ and
138150
# content/docs/ can drift back to teaching the bare `: Page = {}` literal

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs",
3535
"check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n",
3636
"check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs",
37+
"check:stack-collection-maps": "node scripts/check-stack-collection-maps.mjs --self-test && node scripts/check-stack-collection-maps.mjs",
3738
"check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs",
3839
"check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs",
3940
"check:role-word": "node scripts/check-role-word.mjs",

packages/metadata/src/plugin.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ const queryableMetadataObjects = [
5656
// source code (which the legacy FilesystemLoader still scans).
5757
const REPO_SUBDIR = '.objectstack/metadata';
5858

59-
// Map from ObjectStackDefinition field name to MetadataType name
59+
// Map from ObjectStackDefinition field name to MetadataType name.
60+
//
61+
// PINNED against the schema: `scripts/check-stack-collection-maps.mjs` reconciles
62+
// this map with `ObjectStackDefinitionSchema` in both directions, and every
63+
// deviation carries a reason there (#6242). It is one of seven hand-maintained
64+
// enumerations of the same set, and the reason this one has a gate is its own
65+
// history — `docs` and `roles → positions` were each fixed here one line at a
66+
// time, after a missing key silently dropped a whole collection.
6067
const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
6168
objects: 'object',
6269
objectExtensions: 'object_extension',
@@ -89,7 +96,15 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
8996
emailTemplates: 'email_template',
9097
docs: 'doc',
9198
books: 'book',
92-
data: 'dataset',
99+
// `data:` (the SEED collection) is deliberately absent — #6242 row 4(a).
100+
// It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name
101+
// collision `metadata-plugin.zod.ts` warns about in prose. The entry never
102+
// registered anything (SeedSchema declares no `name`, and the loop below
103+
// skips nameless items) — a dead pointer aimed at the wrong kind, which
104+
// would have begun mis-registering the day either side moved. Removed rather
105+
// than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the
106+
// bundle, never registered as metadata items, so a `seed` mapping would be
107+
// new behaviour rather than a corrected name.
93108
};
94109

95110
// ───────────────────────────────────────────────────────────────────────────

packages/spec/liveness/README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,101 @@ re-verify rather than back-filling guesses. **For objectui-side evidence, pin
143143
the commit** (`objectui @732b1bf`) — `action.undoable`'s reader line numbers had
144144
already drifted by 28 lines one day after the issue citing them was filed.
145145

146+
### `producer` — a consumer is only HALF the call graph (#4837)
147+
148+
`live` means **authoring the property changes runtime behaviour**. A cited
149+
consumer proves something *reads* the key. It does not prove the read has an
150+
effect, because a read can depend on a second input that nobody supplies.
151+
152+
`seed.json` marked `Seed.env` **live**, evidence `seed-loader.ts` line 91, note
153+
*"filterByEnv drops datasets whose env list excludes the running
154+
environment."* Every word of that was checkable against the file, and the
155+
verdict was still false:
156+
157+
> Line 91 really did call `filterByEnv(request.seeds, config.env)`. But none of
158+
> the **six** call sites that build a `SeedLoaderRequest` — app boot, per-org
159+
> replay, hot reload, package apply, draft publish, marketplace install — ever
160+
> passed `env`. So `config.env` was permanently `undefined`, `filterByEnv`
161+
> returned its input on its first line, and `dataset.env` was **never read at
162+
> all**. The evidence pointed at the consumer; the property was dead at the
163+
> **producer**.
164+
165+
The unit test made it worse rather than better: `seed-loader.test.ts` has always
166+
had a passing `should handle environment filtering` case — because the test
167+
supplies `config.env` itself. The mechanism was correct throughout; only the
168+
wiring was missing, and the ledger and the test both looked exclusively at the
169+
mechanism. This is the shape Prime Directive #10 already names in another
170+
context: **a `case` label is not enforcement; check the call site** (#3106).
171+
172+
Hence the criterion:
173+
174+
> When a property's runtime effect depends on a **second input that some
175+
> producer must supply**, a `live` verdict requires evidence on the producer
176+
> side too. Cite it in `producer`.
177+
178+
```jsonc
179+
"env": {
180+
"status": "live",
181+
"evidence": "packages/metadata-protocol/src/seed-loader.ts:191 (filterByEnv drops …)",
182+
"producer": "packages/metadata-protocol/src/seed-loader.ts:174 — load() resolves the comparison environment itself (resolveEnvConfig), rather than trusting a caller to pass it"
183+
}
184+
```
185+
186+
`producer` resolves through **the same resolver as `evidence`** — repo-rooted
187+
paths must exist or CI fails; cross-repo paths are attributed and counted. A
188+
call-site claim nothing can falsify is precisely what this field exists to
189+
remove, so it does not get a weaker standard than the pointer it completes.
190+
191+
Which entries need one. The risk is highest for **optional config with a
192+
default**: those always "have a value" in the type system and can still be
193+
`undefined` at runtime.
194+
195+
| Shape | Needs `producer`? |
196+
|---|---|
197+
| The consumer reads the authored value directly (`hook.priority` orders hooks) | no — the author IS the producer |
198+
| The consumer compares the authored value against something a caller supplies (`seed.env`) | **yes** — cite who supplies it |
199+
| The consumer reads it out of an options/config object built elsewhere (`job.timeout`) | **yes** — cite the threading site |
200+
| The property is `dead` | no — there is nothing to produce |
201+
202+
Absence never fails CI (most of the ledger predates the field, and back-filling
203+
guesses is the sin this records). `pnpm check:liveness --producer-gap` prints
204+
every `live` entry citing a consumer only — an upper bound on the debt, to be
205+
triaged with the table above rather than read as a defect list. A **malformed**
206+
value does fail.
207+
208+
### `evidenceScope` — how wide the last look actually was (#4895)
209+
210+
Four measured verdicts were reached by searching **this repo only**, and
211+
published as though they covered every consumer:
212+
213+
| # | Verdict | What the search missed |
214+
|---|---|---|
215+
| 1 | `app.homePageId` tombstoned "no shell ever read it" | objectui's `AppContent.resolveLandingRoute()` had been reading it all along (corrected in #4709) |
216+
| 2 | `flow.nodes.children.position` marked live, "designer canvas layout" | the designer wrote its own `ui:{x,y}` and **nothing** read `position` — a false *live*, the opposite direction |
217+
| 3 | `HttpMethod` reported unused | the scan matched only `import … from` |
218+
| 4 | `Notification` / `NotificationConfig` removed on "zero importers" | objectui re-exported them with `export … from`, and the real consumers imported from `@object-ui/types`**two hops**, so even a scan covering `export … from` misses it while it matches on the spec specifier |
219+
220+
Case 4 is the one that decides the method: **no amount of text or specifier
221+
matching is sufficient**. A negative cross-repo claim has to follow the resolved
222+
symbol graph through re-export chains, or it is a guess with a citation. Every
223+
barrel package adds a blind spot, and the renderer repo is all barrels.
224+
225+
`evidenceScope` records what was actually done, as data:
226+
227+
| Value | Means |
228+
|---|---|
229+
| `in-repo` | the call graph was closed inside this repo only |
230+
| `cross-repo` | a named foreign realm was walked too — say **which**, in the evidence, and **pin the commit** (`objectui @c2fd1223`): `action.undoable`'s reader line numbers drifted 28 lines in one day (#3714) |
231+
232+
Absent = scope undeclared, a worklist row rather than a failure; the field is
233+
younger than nearly every entry. A value outside the vocabulary FAILS, the same
234+
asymmetry as `verifiedAt` — a value the parser cannot read would silently exempt
235+
that entry from every future sweep.
236+
237+
⚠️ Neither `cross-repo` value in the tree today covers **`cloud`**: the closed
238+
runtime is not reachable from an open-source checkout, so a `cross-repo` claim
239+
means "the realms named in the evidence", never "everywhere".
240+
146241
### ⚠️ An authoring/preview renderer is NOT a runtime consumer
147242

148243
`live` means **authoring the property changes runtime behaviour**. A Studio
@@ -522,10 +617,15 @@ over-share.
522617
silently. Same "pure + unit-tested" reasoning as `orphans.mts`, for the same reason.
523618
- `../scripts/liveness/check-empty-state.mts` — the empty-state gate (above);
524619
`empty-state-registry.mts` is its source of truth.
620+
- `../scripts/liveness/producer.mts` — the `producer` / `evidenceScope` fold
621+
(#4837 / #4895). Pure + unit-tested for the same reason as `orphans.mts`: on
622+
the shipped ledgers these checks are almost entirely quiet, so a green gate
623+
proves nothing about whether they can fire.
525624

526625
```bash
527626
pnpm --filter @objectstack/spec check:liveness # run the gate
528627
tsx packages/spec/scripts/liveness/check-liveness.mts --dump field # inventory a type (seeding aid)
628+
tsx packages/spec/scripts/liveness/check-liveness.mts --producer-gap # live entries citing a consumer only
529629
```
530630

531631
CI: `.github/workflows/spec-liveness-check.yml` runs on PRs touching `packages/spec/**`.

0 commit comments

Comments
 (0)