SPEC-144
ID:SPEC-144Status:accepted

Declarative entity and edge registration

changeset-release/main View source
Implemented by 3
Related 18
Branches 4
History 9
  1. b60af32
    Content editedby github-actions[bot]
  2. c9b8c9c
    Created (accepted)by github-actions[bot]
  3. 1046f41
    Content editedby Claude
    docs(plan): add v0.39.0 milestone, Declarations part two
  4. 779099b
    Content editedby Claude
    plan: refine SPEC-143 and SPEC-144, the shared declaration half
  5. 07900ea
    Content editedby Claude
    docs(plan): SPEC-158 — the identity guard is path-granular; SPEC-144 D2
  6. bd2cf7e
    Content editedby Claude
    docs(plan): SPEC-154 — learning audit, where the question becomes "build
  7. ee8bb0c
    Content editedby Claude
    docs(plan): SPEC-152 — plan audit; dedupe inside a plugin that stays
  8. 9a1940b
    Content editedby Claude
    docs(plan): SPEC-147 — replacing the storytelling plugin with composed r
  9. 46ba67a
    Content editedby Claude
    docs(plan): ADR-036 + SPEC-144 + SPEC-145 — user-defined runes, split th

Summary

A rune declares that it registers a named entity, or an edge between two, instead of a plugin writing a register/aggregate hook to do it. The consuming half — the registry, the relationship graph, the runes that query them — already exists and is already generic. Only declaration is imperative, and the imperative code that does it turns out to be data tables written as switch statements.

This is the smallest independently valuable piece of the user-defined-rune programme. It needs neither SPEC-143 nor SPEC-145, ships value to the plugins in this repo on its own, and removes the single largest obstacle to a rune being definable without code.

Background — the power is in the graph, not the transform

What makes the storytelling plugin more than a set of pretty boxes is that a bond resolves between two characters defined on different pages, and that a character's aliases are addressable. That is cross-page behaviour, and it is the part of a domain plugin an author would most want and least be able to write.

The query side of it is already core and already domain-agnostic:

  • EntityRegistry with register, getAll, getByUrl, getById, and getRelated over a relationship graph of ResolvedEdges (packages/types/src/pipeline.ts:40-145, SPEC-072)
  • collection, relationships and aggregate runes that query it generically, with resolvers in packages/runes/src/{collection,relationships,aggregate}-resolve.ts

So a user-defined rune can already be queried. It cannot get into the registry without a plugin.

Problem — the registration code is a data table in disguise

plugins/storytelling/src/pipeline.ts is 319 lines. Its register hook (:140-181) is a loop with no domain logic in it: walk the page's tags, check whether data-rune is in a set of entity types, extract a name, call registry.register({ type, id: name, sourceUrl, data }).

The two helpers it calls are switch statements over the rune name whose every arm is a field list:

function extractEntityName(tag, runeType): string {
  switch (runeType) {
    case 'character': case 'realm': case 'faction': return readRefText(tag, 'name');
    case 'lore':      case 'plot':                  return readRefText(tag, 'title');
    default: return '';
  }
}

function extractEntityData(tag, runeType) {
  switch (runeType) {
    case 'character':
      data.role = readField(tag, 'role');
      data.status = readField(tag, 'status');
      data.aliases = readField(tag, 'aliases');
      data.tags = readField(tag, 'tags');
      break;
    // …five more arms of the same shape

aggregate (:183-220) then builds a name→entity map and expands character aliases from a comma-separated field. Also pattern, also no logic.

The one genuinely different case is bond, and it is different because it is a relationship rather than an entity: its id is ${from}→${to} and it reads two ref fields rather than one name.

Three declarations replace all of it. Nothing in that file requires the ability to run code; it requires the ability to say which field holds the id, which fields to carry, and which two fields form an edge.

Mechanism

A rune's declaration gains an optional registers block. It is inert data — ADR-036's closed-vocabulary rule applies, so there is no expression form and no predicate.

# character
registers:
  entity:
    type: character          # defaults to the rune name
    idFrom: name             # a ref name or a field-bag key
    scope: site              # 'site' (default) | 'page'
    data: [role, status, aliases, tags]
    aliases: { from: aliases, separator: "," }

# bond
registers:
  edge:
    from: from               # ref or field holding the source entity's id
    to: to
    kind: bond               # a literal, or `{ field: kind }` to read one

Core gains one pipeline hook that runs for every rune carrying a registers block, in the existing Phase 2 / Phase 3 slots described in CLAUDE.md. Plugins keep their own hooks for everything else; this adds a generic participant, it does not replace the hook mechanism.

Where the values come from. idFrom and data name either a refs entry (read as its text content, as readRefText does today) or a data-rune-fields bag key (as readField does). The two namespaces are disjoint, so a single name resolves unambiguously — the implementation tries the ref first and falls back to the bag, exactly as the hand-written helpers already do in aggregate. Whether that fallback stays implicit or the declaration must say which channel is D3.

Reach — honest, and narrower than it looks

Three plugins have pipeline hooks. They do not have the same shape, and this spec should not pretend otherwise:

Pluginpipeline.tsShapeReachable?
storytelling319Generic walk + two field-list switches + alias expansion; one relationship caseYes — the motivating case
design92Same shape, smallerYes
plan1,120configure hook, module-level state, an unconditional filesystem scan outside the content tree, dependency edges parsed from H2 prose sections, Blocks edges belonging to a file that does not own the entityNo

So the claim is: two of three plugins can shed their registration code, and the third demonstrates why the hook mechanism has to stay. The plan plugin is the counter-example that keeps this spec from overreaching — its edges come from prose, its scan reaches outside any site, and no field-list declaration expresses either.

Re-verified, and the table holds exactly: plugins/storytelling/src/pipeline.ts is 319 lines, design's is 92, plan's is 1,120. D2's five first-party sentinel instances also land where cited — packages/runes/src/config.ts:214, :238, :244, :250 and :966, each on a comment describing that exact emit-then-resolve pattern.

And this reach is measured, which the greenfield consumers named elsewhere are not. SPEC-154 calls prerequisite this spec's first greenfield consumer, and prerequisite does not exist — WORK-011 is pending and plugins/learning/src/tags/ holds howto.ts and recipe.ts and nothing else. That is a fair thing to say about a planned rune and a wrong thing to add to the table above. Recorded because ADR-039 rule 2 made precisely that addition and had to retract it: planned consumers justify a mechanism's shape, never its reach.

What this unlocks that is not in this repo

A user-defined rune with a registers block participates in the cross-page graph with no code. That means the collection, relationships and aggregate runes work against user domains on day one — which is the difference between a user-defined rune being a styled box and being a content type.

It is also the piece that makes SPEC-145's composed runes worth having: a composed rune's tree is built from primitives, so its identity has to come from somewhere, and a registers block on the outer declaration is where cross-page identity lives.

Decisions

D1 — the vocabulary is closed, per ADR-036

entity and edge, with named fields. No expression form for idFrom, no predicate for "which tags count", no computed kind beyond reading a named field. A domain that needs more writes a plugin.

D2 — it declares registration, never resolution

The declaration says what enters the registry. What comes out is the existing query runes' business. In particular this spec does not add a way to declare a postProcess sentinel resolution — that is where the remaining storytelling pipeline code lives, and it stays imperative.

Revised: the exclusion splits on the scope of the write, not on the hook

Two audits pushed back on this, and they turn out to be describing different things. SPEC-147 found that excluding postProcess loses a real capability, and SPEC-154 found that glossary cannot exist without one. Treating those as the same objection is what made D2 look either too strict or too loose depending on which you read last. They separate cleanly:

Bounded — a rune resolves a placeholder it emitted itself, from aggregated data. This is a pattern with five first-party instances, all the same shape, all in packages/runes/src/config.ts: expand (:214), collection (:238), file-ref (:244), aggregate (:250) and buildAutoBreadcrumb (:966). Storytelling's is a sixth. The declaration is a lookup — which registry, keyed by what, replacing which sentinel — not arbitrary code, and it is squarely within what this spec's vocabulary can express.

Unbounded — a rune rewrites content it did not emit. glossary's own note is the statement of it: "the pipeline collects all glossary terms and rewrites matching text nodes across other pages into links." No declaration expresses that without becoming a language, which ADR-036 forbids, and the failure mode is a page changed by a rune that does not appear on it.

So: the bounded case becomes declarable; the unbounded case is the plugin escape hatch, permanently. That satisfies both witnesses rather than splitting the difference — storytelling's capability is declared rather than lost, and glossary stays a plugin rune, which is what SPEC-154 D2 concluded independently on its own evidence.

The line is checkable, which is what makes it a rule rather than a judgement: a bounded resolver only ever writes to nodes carrying its own rune's sentinel.

D3 — one open question is deliberately deferred: channel explicitness

Whether idFrom: name must say ref:name / field:name, or whether the implicit ref-then-bag fallback is good enough. The fallback matches today's behaviour and reads better; explicitness catches a typo that would otherwise register an entity with an empty id. Decide with a migration in hand, not before.

D4 — no rune migrates until the registry output is proved identical

Same gate as SPEC-143 D7. A migrated plugin must produce a byte-identical registry — same types, ids, scopes, sourceUrls and data bags, in the same registration order — because registration order decides last-write-wins on a site-scoped collision (packages/types/src/pipeline.ts:46-56).

D5 — plan is out of scope and stays out

Not a phasing decision. Its registration reads prose sections and scans the filesystem; forcing it into a declaration would either expand the vocabulary until it is a language (ADR-036) or produce a declaration that lies about what runs.

Non-goals

  • Replacing PluginPipelineHooks — this adds a generic participant beside them
  • Declaring unbounded postProcess rewriting — a rune changing content it did not emit (D2, revised). The bounded case is in scope, phased below
  • Declaring the configure hook, or anything that reads the filesystem
  • Changing EntityRegistry, the relationship graph, or any query rune
  • Making the plan plugin declarative (D5)
  • Cross-page validation of declared entities — a dangling edge target is the registry's existing concern, unchanged here

Phasing

Accepted for v0.39.0 as registration only: registers.entity / registers.edge, the core hook, and the storytelling and design migrations (WORK-611, WORK-612, WORK-613). The last two criteria below, which make bounded sentinel resolution declarable and migrate the five first-party instances, are a follow-up. One of those instances is buildAutoBreadcrumb, which BUG-018 changes in the same milestone. Migrating a function while its output is being corrected would leave D4's identical-output gate with nothing stable to compare against.

Status after v0.39.0. Registration shipped in v0.39.0 (refrakt-md/refrakt#670), and BUG-018 is fixed, so buildAutoBreadcrumb is stable again. The spec stays accepted, not implemented, because the sentinel-resolution half is still outstanding. plan status suggests the flip because every linked work item is done, but those items cover only the registration half.

Acceptance Criteria

  • A rune can declare registers.entity with type, idFrom, scope, data and aliases
  • A rune can declare registers.edge with from, to and kind
  • The declaration contains no function values and round-trips through JSON.parse(JSON.stringify(…))
  • One core hook performs the registration for every rune carrying the block, in the existing Phase 2 / Phase 3 slots
  • storytelling's register and aggregate hooks are deleted, not merely unused, and its postProcess is untouched
  • design's register and aggregate hooks are deleted the same way
  • The registry produced for the site fixtures is identical before and after each migration — types, ids, scopes, sourceUrls, data bags, and registration order (D4)
  • bond edges resolve through getRelated identically to today
  • character alias lookup resolves identically, including the "first registration wins" behaviour for a duplicate alias
  • A declaration naming a field that no emitted node, field-bag entry or attribute provides is reported at validate time, with file and line — the same treatment a schema-table source already gets
  • refrakt inspect shows a rune's registration declaration
  • The generated reference documents registers for every rune that carries one
  • The plan plugin's pipeline is unchanged, and the spec's reach table is reflected in the authoring guide
  • A bounded sentinel resolution is declarable, and the five first-party instances (expand, collection, file-ref, aggregate, buildAutoBreadcrumb) each produce identical output through the declaration (D2)
  • A declared resolver that writes to a node carrying another rune's sentinel is rejected, so the bounded/unbounded line is enforced rather than documented (D2)

References

  • SPEC-072 — the relationship graph and the query runes this declares into
  • SPEC-143 — declarative slot labelling; the same closure-over-data approach applied to the transform
  • SPEC-145 — composed runes; needs this for a composed rune's cross-page identity
  • ADR-036 — why the vocabulary is closed rather than an expression language
  • SPEC-064 — plan content registered from outside a site's content tree; part of why plan is out of scope
  • SPEC-147 — the storytelling replacement; the consumer that motivated this spec, and which finds D2's postProcess exclusion loses a capability
  • SPEC-152 — the plan audit; why D5's exclusion leaves the plugin's runes composable anyway
  • SPEC-154 — the learning audit; prerequisite is this spec's first greenfield consumer, and the planned glossary is the unbounded case D2 now names
  • SPEC-158 — the identity guard's granularity; the other axis the refinement pass separated from this one
  • ADR-039 — where counting a planned rune as measured evidence was caught and retracted
  • WORK-011 — prerequisite; pending, and the greenfield consumer the reach table does not count