Systematizes what #4074 / objectui#3003 / objectui#3009 did by hand for the third time. Filed so the rule outlives the sessions that keep rediscovering it.
The failure class
objectui declares local types/consts under the same names as @objectstack/spec exports, with doc comments claiming spec canonicity. Every audited instance had either drifted or was one spec release away from it:
| symbol |
comment claimed |
code did |
found |
ActionType |
"canonical definition from @objectstack/spec" |
hand union, missing form |
#2231/#2901 |
ChartType |
(sibling schema re-exported under spec's name) |
7 of 19 members |
objectui#2944 |
ActionLocation + 2 |
"single source of truth… re-export here" |
re-declared union + tuple + z.enum |
#4074 |
ActionParamFieldType |
"aligned with the field types available in spec" |
16 of 49 members |
#4074 |
The mechanism that makes this class expensive is specific to agent-driven development: an agent reads the comment, takes it as ground truth, and builds on it. #2901 was literally filed with a backwards premise because the fork was re-exported under the spec's own symbol name. A wrong canonical-claim is not stale documentation — it is a planted premise for the next session.
And the absence of derivation is what made #3856 silent: spec 17 removed action.execute and objectui produced zero compile errors, because nothing was bound to the spec's z.infer.
The rule
Any exported type or const in objectui whose name matches a @objectstack/spec export must be an import/re-export of it — or carry an allowlist entry with a reason.
Enforced by a CI script (~100 lines), not prose:
- Enumerate spec's export names per subpath (
@objectstack/spec/ui, /data, …) at runtime.
- Grep objectui
packages/*/src for export (type|const|interface) <SameName> declarations not backed by an import from spec.
- Fail with the symbol, the file, and the fix ("import it, or add an allowlist entry with a reason").
Value exports additionally pin by reference identity (expect(X).toBe(SpecX)), because a faithful copy passes every value comparison — identity is the only check that distinguishes a re-export from a fork. This insight is already written down in objectui's spec-subschema-parity.test.ts and already applied to ACTION_LOCATIONS / ActionLocationSchema / ACTION_PARAM_FIELD_TYPES (objectui#3003); this issue is about applying it uniformly instead of one symbol per incident.
The allowlist (what keeps this from being a nuisance)
Same governance as check-type-check-coverage.mjs's DEBT map — declared, reasoned, shrink-only. Known-legitimate first entries:
@object-ui/types ActionSchema — a deliberate objectui-side view over the spec's action (carries renderer-only fields like openIn); documented as such.
ObjectUiLocalActionType / ObjectUiLocalParamFieldType — named local dialects, each with a tripwire test asserting the spec does not own the name (so the day spec adopts it, CI names the alias to retire). This is the sanctioned escape hatch for "implementation accepts more than spec": declare the dialect next to the vocabulary it extends, never hide it in a Record<string, string> in another package.
- The
legacy ActionSchema in crud.ts and similar explicitly-legacy shapes, if any collide.
A same-name symbol that is narrower or wider on purpose belongs in the allowlist with that purpose written down — at which point the comment is load-bearing instead of decorative.
One meta-rule
This rule must land as the check, not as an AGENTS.md paragraph. A prose-only rule is precisely the "declared ≠ enforced" landmine this whole thread is about (#4074, objectui#3009 — where a guard file's own header falsely claimed its checks were "the real enforcement" for the entire interval nothing compiled it).
Refs #4074, #3856, objectui#3003, objectui#3009, objectui#2944, #2901, #2231.
Systematizes what #4074 / objectui#3003 / objectui#3009 did by hand for the third time. Filed so the rule outlives the sessions that keep rediscovering it.
The failure class
objectui declares local types/consts under the same names as
@objectstack/specexports, with doc comments claiming spec canonicity. Every audited instance had either drifted or was one spec release away from it:ActionTypeformChartTypeActionLocation+ 2z.enumActionParamFieldTypeThe mechanism that makes this class expensive is specific to agent-driven development: an agent reads the comment, takes it as ground truth, and builds on it. #2901 was literally filed with a backwards premise because the fork was re-exported under the spec's own symbol name. A wrong canonical-claim is not stale documentation — it is a planted premise for the next session.
And the absence of derivation is what made #3856 silent: spec 17 removed
action.executeand objectui produced zero compile errors, because nothing was bound to the spec'sz.infer.The rule
Enforced by a CI script (~100 lines), not prose:
@objectstack/spec/ui,/data, …) at runtime.packages/*/srcforexport (type|const|interface) <SameName>declarations not backed by an import from spec.Value exports additionally pin by reference identity (
expect(X).toBe(SpecX)), because a faithful copy passes every value comparison — identity is the only check that distinguishes a re-export from a fork. This insight is already written down in objectui'sspec-subschema-parity.test.tsand already applied toACTION_LOCATIONS/ActionLocationSchema/ACTION_PARAM_FIELD_TYPES(objectui#3003); this issue is about applying it uniformly instead of one symbol per incident.The allowlist (what keeps this from being a nuisance)
Same governance as
check-type-check-coverage.mjs's DEBT map — declared, reasoned, shrink-only. Known-legitimate first entries:@object-ui/typesActionSchema— a deliberate objectui-side view over the spec's action (carries renderer-only fields likeopenIn); documented as such.ObjectUiLocalActionType/ObjectUiLocalParamFieldType— named local dialects, each with a tripwire test asserting the spec does not own the name (so the day spec adopts it, CI names the alias to retire). This is the sanctioned escape hatch for "implementation accepts more than spec": declare the dialect next to the vocabulary it extends, never hide it in aRecord<string, string>in another package.legacy ActionSchemaincrud.tsand similar explicitly-legacy shapes, if any collide.A same-name symbol that is narrower or wider on purpose belongs in the allowlist with that purpose written down — at which point the comment is load-bearing instead of decorative.
One meta-rule
This rule must land as the check, not as an AGENTS.md paragraph. A prose-only rule is precisely the "declared ≠ enforced" landmine this whole thread is about (#4074, objectui#3009 — where a guard file's own header falsely claimed its checks were "the real enforcement" for the entire interval nothing compiled it).
Refs #4074, #3856, objectui#3003, objectui#3009, objectui#2944, #2901, #2231.