Goldie Blocks

References Bind To Stable IDs, Not Handles

Why every stored reference points at a stable identity with the handle as a display hint, and why the Obsidian-style wikilink model was abandoned

Decision

Every stored reference binds to a stable identity. The handle is a display hint, never the pointer.

Terminology — 2026-08-15

An authored @ token is a Mention. Resolving that Mention against the current document produces a Resolved Mention: a contextual projection with the target's current name and content, not a second stored object. “Reference” remains a useful generic word for relationships elsewhere in the system, but it does not name this authored-token-to-context pipeline.

The stored form is <@id|hint>, parsed and rendered in packages/domain/src/mention/mention-language.ts, and carries { id, hint, modifier }. The app resolves by id. The hint carries the authored handle so stored content stays human-readable and greppable, and so a rename can refresh the displayed text without touching the binding.

This applies anywhere a handle is stored as a pointer. A handle used as a display value, or as the address a human types, is unaffected — authors go on writing @executor_details, and the UI goes on showing it.

Context

The earlier intent was to run the whole product offline over plain files with skills, using Obsidian-style [[wikilink]] references: the human-readable name was the pointer. That model is attractive on paper — files stay portable, and any tool that can read text can follow a link.

It does not survive rename. A handle that appears in a dozen references becomes a dozen breakages the moment it changes, and every one of those sites must be found and rewritten for the document to stay correct. Correctness that depends on a rewrite sweep completing is not correctness.

The priority is that the app works seamlessly. That requires that a correct reference stays correct when a handle is renamed, which requires binding to something that does not change. Offline is now a secondary venue.

What this supersedes

Code written under the wikilink model still exists and reads as deliberate. Two known sites:

  • parseFlowFacet in packages/domain/src/block/block-facet-authoring.ts strips the @ from after: and stores the bare handle, commented as intentional — "both name the same sibling. Storage is the plain handle." That comment records the superseded model.
  • updateBlockLabel used to rewrite siblings' flow.after when a block was renamed. Gone (verified 2026-08-02). The About cutover (7165737) replaced the three-way Label/Display/Flow dispatch with updateBlockAbout and took the rename sweep with it. The only surviving mention of updateBlockLabel is a fixture string in src/lib/block-notification.node.test.ts:106. This bullet is kept because the reasoning still explains why the sweep existed — not because the code does.

Treat a handle-storage comment as evidence of the old model, not as a constraint to preserve.

Amendment, 2026-07-29 — two writers still mint handles

An architecture review found that this decision landed in only one of the three sites that write flow.after. updateBlockFlow resolves to blockKey; addDataBlock (convex/documents.ts:1358-1360) and blockReuse.ts:110-112 both still store a handle.

The consequence is sharper than drift: a backfill of existing handle pointers cannot converge while those two keep writing the old form. The rename sweep above therefore cannot simply be deleted in favour of a one-shot backfill — the writers must be fixed first.

The settled order is recorded in Block Edits Run Inside One Envelope: extract resolveFlowAfter as a pure function and route all three writers through it, then backfill, then delete the sweep and this section's second bullet with it.

Status, 2026-08-04 — migration and anchor retirement complete

Re-verified against the tree:

  • Step 1 (fix the writers) is done. resolveFlowAfter is extracted at convex/blockFold.ts:335, and blockReuse.ts:131 routes through it. The blocking claim above — that two writers still mint handles — no longer holds.

  • Step 3 (delete the sweep) is done, though it landed out of order: the About cutover removed updateBlockLabel wholesale rather than the backfill clearing the way for it.

  • Step 2 has now run on dev (2026-08-02). backfillFlowAfterKeys (convex/documents.ts:1228) rewrote 7,441 handle-form pointers across 120 documents with 0 dangling. It is routeKey-scoped, so it is run per document.

    A second migration was needed alongside it and is easy to miss: backfillFlowAfterKeys only rewrites pointers, and does not write order.position. blockOrderMigration.migrateBlockOrder does that — on dev it positioned 1,142 modules and 8,606 blocks that were still sorting via the anchor fallback. Run that one first; it derives positions from the anchor chain, so it needs the relationships intact.

    Both are idempotent — a second full pass reported zero writes and skipped everything.

    Production is empty and needs no data rewrite. On 2026-08-04 the current Convex functions were deployed to production, then the formal migrateBlockOrder dry run scanned zero document versions and reported zero module or block writes with no anchor problems. A read-only count confirmed zero documentVersions, moduleVersions, and blockVersions, so there are no routeKey values for backfillFlowAfterKeys to process. Neither non-dry migration was run.

    Two blockVersions remain without a position permanently: they carry no moduleVersionKey, so no parent module or document owns them and no migration can reach them. Orphaned rows, not a migration defect — but verifyBlockOrderMigration will always report blocksWithoutPosition: 2.

    Do not read blockFold.ts:269's note about the fold tolerating bare handles as evidence the backfill is unwritten. That comment describes the reader, and the reader keeps its legacy fallback until the rows are known clean on every deployment that matters.

The out-of-order deletion is worth naming: this section warned that deleting the sweep before backfilling "makes the fold's tolerance of handle pointers permanent." That has effectively happened — the tolerance is now load-bearing until the backfill runs, rather than a temporary accommodation. It is not broken, but the safety margin the ordering was protecting is spent.

The completed migration gate allowed the anchor machinery to be retired on 2026-08-04. Current reads sort only by order.position; current insertion paths write scalar positions; the migration functions and pointer backfill are gone. The detail above is retained as migration history.

Relationship to the no-runtime local venue

Deterministic Write-Safety For Hosted App Flow Conditions states a target of no-runtime local execution: an agent reads authored module files directly, without Convex or an executable resolver. That target is not withdrawn, but it is now explicitly secondary to the hosted app.

The hint is what keeps it reachable. A stored reference remains legible without a database, so a local agent can read and reason about authored files. What is not guaranteed is that the hint alone resolves unambiguously — no dual-resolution machinery is built, and handle uniqueness is not strengthened to support binding by hint. If the local venue later needs guaranteed resolution without identity lookup, that is a new decision.

Import-reconciliation sketch, 2026-08-08 (design intent, unbuilt)

When the local round trip is built, the owner-settled sketch is:

  • Export carries keys. The YAML exports each block/module/field's stable key beside its handle (the Identifier Model already promises "UI never shows it, export does"). On re-import, a known id is an update to that thing regardless of its current handle; the handle is a display hint catching up.
  • Offline minting uses ULID-shaped keys. An unfamiliar well-formed id imports as new. A familiar id whose row contradicts the import's claim (wrong kind, wrong scope) is refused — that is duplication or tampering, not a collision.
  • The ordering invariant that must not be violated: resolve identities first; then compile every reference within the import's own namespace (an offline-authored @executor_details binds to the import's block of that handle before anything else is consulted); only then reconcile handle collisions. Suffixing (executor_details_2) is safe only after binding, when a handle is purely a display hint — suffixing before compilation silently re-points intra-import references at pre-existing blocks, which is the silent-wrong-output failure class. Every suffix is reported, never silent; the interactive refuse-don't-suffix rule stays, since import has no human at write time and suffix-and-report is its batch equivalent.

Consequences

  • venue: "after" in ReferenceCompileAdapter becomes meaningful. It is declared but never read; it was placed for this and never wired.
  • The temporary flow.after identity migration is complete and its runtime resolver has been deleted. Stable-ID binding remains the rule for references; ordering no longer stores a pointer at all.
  • The field-identity backfill already landed for exactly this reason: field declarations carry stable fld_ identities before any reference binds to them. This decision generalizes that invariant beyond fields.

On this page