INFRA-030: Reference registry — the rows a rule names, as edges (design ticket) - #92
Closed
agreenspan wants to merge 14 commits into
Closed
agreenspan wants to merge 14 commits into
agreenspan wants to merge 14 commits into
Conversation
agreenspan
force-pushed
the
INFRA-030-reference-registry
branch
from
September 8, 2026 19:37
226f89a to
578f882
Compare
…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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014LrHJDW6ybsP8VyX26ioxz
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
force-pushed
the
INFRA-030-reference-registry
branch
from
September 10, 2026 13:50
ea60bc4 to
55547e9
Compare
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The reference registry — the rows a rule names, as edges — with email conditionals (
{{#if rule=…}}inEmailTemplate.subject/.mjmlandEmailComponent.mjml) as the first surface, pluswithRule, 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.md→ Rule References.RuleReference— false-polymorphic on both axes (both inPolymorphismRegistry), 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 (noonDelete, so a purgeSET NULLs it),referencedIdis the name (written once). Two clocks on one fact; their divergence is the signal.ruleSourceValues(lens, rule); the email narrowing ismapDefaults-shaped so a source on each referenceable model's id answers on every path and FK spellings are omitted. Extraction surface = authoring surface.saveEmailTemplatecallssyncRuleReferences(owner, contents, lens)in its transaction for the template and each component. Vocabulary anddynamicrefused; a newly added reference must resolve to a live row (delta-only, so a pre-existing dead reference stays editable); referenced rows locked withfindForUpdatewhile the gate reads them.ruleReference:referenced, copies a target'sdeletedAtonto the edges naming it (cleared on undelete).ruleReferenceIssues(edges)readsdeleted/purgedfrom 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.evaluateConditionsruns 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
data.*paths for that reason — a made-uprecipient.tieris a vocabulary violation.Gates
Rebased onto
main(was 16 behind); json-rules^2.21.1everywhere, lock regenerated.packages/email121/121 ·packages/shared135/135 (7 new forwithRule) ·packages/db293/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