Skip to content

INFRA-030: Reference registry — the rows a rule names, as edges (design ticket) - #92

Closed
agreenspan wants to merge 14 commits into
mainfrom
INFRA-030-reference-registry
Closed

agreenspan wants to merge 14 commits into
mainfrom
INFRA-030-reference-registry

Conversation

@agreenspan

@agreenspan agreenspan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

The reference registry — the rows a rule names, as edges — with email conditionals ({{#if rule=…}} in EmailTemplate.subject / .mjml and EmailComponent.mjml) as the first surface, plus withRule, the one fork every stored rule is evaluated through. Design, rulings and the adversarial record: tickets/INFRA-030-reference-registry.md; overview: docs/claude/COMMUNICATIONS.mdRule References.

  • RuleReference — false-polymorphic on both axes (both in PolymorphismRegistry), append/delete only, one unique per owner column. Owner side cascades. Referenced side carries true poly beside the false: the typed FK is the relation (no onDelete, so a purge SET NULLs it), referencedId is the name (written once). Two clocks on one fact; their divergence is the signal.
  • Extraction is the lens's — json-rules 2.21.1 ruleSourceValues(lens, rule); the email narrowing is mapDefaults-shaped so a source on each referenceable model's id answers on every path and FK spellings are omitted. Extraction surface = authoring surface.
  • Edges come from the save path, not a hooksaveEmailTemplate calls syncRuleReferences(owner, contents, lens) in its transaction for the template and each component. Vocabulary and dynamic refused; a newly added reference must resolve to a live row (delta-only, so a pre-existing dead reference stays editable); referenced rows locked with findForUpdate while the gate reads them.
  • Staleness is asked, not stored — the one hook, ruleReference:referenced, copies a target's deletedAt onto the edges naming it (cleared on undelete). ruleReferenceIssues(edges) reads deleted / purged from the edge rows alone. No projection, no closure, no propagation.
  • withRule({ lens, rule, references, live }, { degraded, sound }) in @template/shared/rules — pure. Asks at evaluation, against the current lens: does the lens still admit the rule (so lens drift after save degrades instead of silently narrowing), does it name its rows rather than read them dynamically, is every named row in the live set the caller confirmed (absent set = nothing confirmed = missing). Degraded means do nothing new and say why; sound runs the caller's evaluator. evaluateConditions runs every branch through it. Existing state is never touched by a degraded rule.

Zealot's #2116 / #2142 are the same registry on MySQL; its consumer PRs (#2201, #2216, #2217) port onto withRule — see the ticket's Zealot follow-through.

Contract changes worth knowing

  • An omitted live set now fails closed in the renderer: a branch naming a row with no live set is a rule error. Previously it evaluated.
  • Rules are checked against the real lens at evaluation, not only at save. The renderer tests moved onto data.* paths for that reason — a made-up recipient.tier is a vocabulary violation.

Gates

Rebased onto main (was 16 behind); json-rules ^2.21.1 everywhere, lock regenerated.
packages/email 121/121 · packages/shared 135/135 (7 new for withRule) · packages/db 293/293 · api hooks + email lib 212/212 · typecheck clean on shared, email, db, api · biome clean on touched files.

🤖 Generated with Claude Code

https://claude.ai/code/session_014LrHJDW6ybsP8VyX26ioxz

@agreenspan
agreenspan force-pushed the INFRA-030-reference-registry branch from 226f89a to 578f882 Compare September 8, 2026 19:37
agreenspan and others added 13 commits September 10, 2026 10:49
…gn ticket)

Template half of Zealot ZLT-4441 / #2116. False-polymorphic RuleReference on both
axes (AuditLog's pattern, cascade on the referenced side), surface registry + after-hook
writing edges in the save's transaction, staleness re-resolved on the referenced side
when deletedAt flips, save gate + cycle check reading the registry. Extraction is a lens
fact (ruleReferences in json-rules' lens module; this is its named first consumer).
Email componentRefs stays slug-keyed — ruling recorded. Code lands with the first
template surface (email conditions via INFRA-017/018); none exists today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ
…ditionals are the first surface

RuleReference with false polymorphism on both axes (typed FKs, onDelete: Cascade,
partial-unique edge identity, PolymorphismRegistry entries). Write hook recomputes an
owner's edges in the save's transaction on every subject/mjml touch (set-diff; missing,
soft-deleted, or path/bind-dynamic references are a 422). Staleness hook re-resolves every
owner over the reverse edges when a referenced row's deletedAt flips, writing the
degradedRuleRefs projection; composeTemplate unions template + expanded components and a
branch naming a stale row is a rule error routed through onError — a stale rule is never
evaluated. Extraction is the lens's: json-rules 2.20.0 ruleSourceValues over
emailRuleNarrowing (recipient → User; tag/organization/space id sources); component
componentRefs stay slug-keyed by ruling. 13 DB hook tests + 6 extraction + 2 render tests;
email 110/110, api hooks+email 219/219, typecheck clean.

Pins move to json-rules ^2.20.0 — bun.lock intentionally not regenerated until 2.20.0 is
on npm (npm publish pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ
…rojection keyed

Three-agent findings, each fixed and pinned by a test. Extraction surface now equals the
authoring surface: mapDefaults sources on each referenceable model's id (answer on every
path), prismaMap-derived FK-column omits, and checkRuleAgainstLens run in the write hook —
an FK spelling, typo path, or dotted-through-list reference is a 422, never a silently
unregistered rule; an undeclared relation path to a referenceable id registers. Races
fenced with db.findForUpdate (extended to { id: { in } }): the save gate locks referenced
rows before liveness, reresolveDegraded locks owners before computing. The gate validates
the delta (pre-existing dead refs stay editable and flagged; only new ones refuse);
archived owners keep their projection maintained via db.withDeleted; degradedRuleRefs
holds Model|id keys and defaults to [] at the DB. Render evaluates over the nested
{sender, recipient, data} object; dynamic rules are a rule error unconditionally;
unterminated {{#if}} blocks are reported and suppressed instead of shipping raw rule JSON;
a malformed nested marker can no longer bisect the outer block via a {{/if}} inside a JSON
string. Hook tests run the full prod hook set (scoper + preventHardDelete + rules) and
reset the scoper after.

Suites: email 119/119, api hooks+email 225/225, typecheck clean on db/email/api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ
…d reference clears flag and edge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FacfdVGsUptpixe731XXZQ
…ts hook

"Which of my references are gone" is a question the referenced rows already
answer. Storing the answer bought nothing and cost a column on every owner, a
hook on every referenced model, a re-resolve walk, a lock, and an invariant that
could drift from the rows it described.

composeTemplate now resolves the references of the content it just expanded in
one query and passes the LIVE keys down. A branch naming a row outside that set
is a rule error, never a match. Absence is the answer, so never created, soft
deleted, and hard deleted with the edge cascaded away all fail closed without
the render telling them apart — and the archived-owner and purge-ordering cases
stop existing rather than being handled.

Gone: degradedRuleRefs on EmailTemplate/EmailComponent, ruleReference/degraded.ts,
reresolveDegraded, writeDegraded, degradedFrom, hydrated, collectDegradedRuleRefs
and its cascade walk.

Kept: the edge table, for the two questions a row cannot answer about itself —
who references X, and may this save name that row. The delta gate keeps its
findForUpdate fence, which is now the only lock in the feature.

findForUpdate: an empty in-list locks nothing instead of throwing, and a
predicate naming a field the map does not know is refused rather than
interpolated into SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq
… deletedAt denormalised

Staleness is two signals on the edge row, not a computed flag and not a deep
include.

An edge has to survive the row it names. The referenced FK loses its onDelete,
so a purge SET NULLs it rather than deleting the evidence, and the axis gains
`referencedId` — true polymorphism beside the false — so the edge still names
the row that went. The two are clocks on one fact: the FK is the relation, owned
by referential integrity, and goes null the moment the row ceases to exist;
`referencedId` is the name, owned by the rule content, written once. They agree
while the target lives and diverge exactly where it matters.

PolymorphismRegistry learns `idField` and keeps them in step at write time,
admitting the all-FKs-null branch as legal — reachable only through a
referential action, never through a write. That axis shape also covers a bare
pair and a typed-FK-only subject, so AuditLog and Token stop being different
kinds of thing from this table.

Soft delete is the other signal: ruleReference:referenced copies the target's
deletedAt onto the edges naming it, one updateManyAndReturn per model, matched
on the true-poly pair so a purged edge is never rewritten. No walk, no closure,
no transitive flag — one scalar across one hop, which can be stale but never
subtly wrong.

The payoff is the read. ruleReferenceIssues(edges) is pure and takes no
relations, so a consumer writes `include: { ruleReferences: true }` and never
grows that include as models become referenceable. composeTemplate reads the
template's edges plus those of the components the cascade actually resolved —
`expand` now returns their ids — instead of re-parsing the composed content.

Also: REFERENCED_MODELS comes from the lens instead of the FK map. Deriving what
a rule may name from which columns happen to exist let storage grant and revoke
vocabulary silently; axisKey already fails loudly when the schema has not caught
up, which is the right direction. Six partial uniques collapse to two now the
discriminator is inside the key.

The lens-as-data question is written into the ticket as an open item, not
answered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq
syncRuleReferences writes referencedId and the typed FK from the same value on
the same line, so a registry-driven rule enforcing that they match guards a
one-line assignment. The PolymorphismRegistry axis loses idField and toRules
goes back to shape only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq
The only hook left is on the referenced side. Everything the owner-side hook
did — sniff args for the rule column, guess the upsert arm, key a surfaces
registry by model — is gone; syncRuleReferences(owner, contents, lens) is a
service in packages/email/src/rules, and saveEmailTemplate calls it inside its
transaction for the template and each component it saved. That is the only
writer of mjml/subject in the repo, so there is nothing for a hook to catch that
the call does not.

Deleted: hooks/ruleReference/hook.ts, surfaces.ts, sync.ts, touchesSurface,
RULE_REFERENCE_SURFACES, REFERENCED_MODELS. The referenced hook reads the
referenceable set from the lens and the FK column from the registry directly.

The gate throws RuleReferenceError — a sibling of ConditionValidationError on
the same path — because packages/email has no hono dependency and makeError
lives in the api.

Two things the move surfaced. The stored template body keeps each component
block inline, so its own references are the ones left once those blocks are
emptied; cleanRefs now accepts the stored (untagged) form as well as the tagged
intermediate, and the template is synced against cleanRefs(mjml). And
packages/email's own save test used recipient.role in a fixture — a path the
lens never resolved — which the api-side hook had let through because the gate
did not run there; the fixture names recipient.email now.

Tests go through saveEmailTemplate rather than the factories, since factories
write rows without edges by design.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq
syncRuleReferences took a lens and ignored it — ruleReferences, contentRuleReferences,
ruleVocabularyIssues and contentVocabularyIssues all closed over emailRuleNarrowing. Now the
lens is the first argument of each, the memo is keyed per lens (the same rule read through a
different lens names different rows), and the render path passes the base lens explicitly. This
is the seam the parked "lens that lives in a row" item needs; nothing about behaviour changes
while there is one lens.

Also: the schema header still credited the deleted surfaces registry, the ticket still said
"write hook" and "422" for a gate no route calls, and the test file was named for a module that
no longer exists — hook.test.ts is ruleReference.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbpGbmiae5VHdHmPVin1bq
A stored rule degrades two ways: the lens stops admitting it, or a row it
names is gone. Both are asked at evaluation, against the current lens and
the caller's live set, in one pure helper in @template/shared/rules with two
arms — degraded (do nothing new, say why) and sound (evaluate). The email
renderer is the first consumer; Zealot's sweep, match filters and
auto-approval port onto the same call.

An omitted live set now fails closed: nothing confirmed means every named
row is missing. The renderer tests move onto lens-admitted `data.*` paths,
since a made-up recipient column is now a vocabulary violation at
evaluation, as it already was at save.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LrHJDW6ybsP8VyX26ioxz
…m is retired

withRule asks two questions: is every required binding supplied, and is the rule
still valid (lens admits it, every named row live). A value read through path or
bind names no row: no edge, no refusal. Ruling 2026-09-10.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
@agreenspan
agreenspan force-pushed the INFRA-030-reference-registry branch from ea60bc4 to 55547e9 Compare September 10, 2026 13:50
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
@agreenspan

Copy link
Copy Markdown
Contributor Author

Superseded: this branch's content landed on main through #102 (merged there after the 2026-09-10 ruling reduced withRule to two failure paths). #100 is being rebased onto main.

@agreenspan agreenspan closed this Sep 10, 2026
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