Skip to content

fix(registry): carry dependency root status across the Lua boundary - #557

Merged
wolfy-j merged 3 commits into
mainfrom
fix/lua-registry-dependency-root
Aug 8, 2026
Merged

fix(registry): carry dependency root status across the Lua boundary#557
wolfy-j merged 3 commits into
mainfrom
fix/lua-registry-dependency-root

Conversation

@wolfy-j

@wolfy-j wolfy-j commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The defect

luaTableToEntry (runtime/lua/modules/registry/utils.go) read only id, kind, meta and data. entryToLuaTable emitted the same four. Entry.DependencyRoot was therefore invisible in both directions, so every entry written through the Lua registry module persisted with the flag cleared, and every read-modify-write cleared it again. All Lua write paths go through it (changes.go:60,88, delta.go:47,58).

Why it stayed hidden

isRootDependency (boot/deps/hub/dependency_control.go:16) is:

entry.Kind == regapi.NamespaceDependency && (entry.DependencyRoot || entryModule(entry) == "")

With the flag gone, root status hangs entirely on the meta.module fallback. On a source-run app meta.module is empty, so the fallback returns true and nothing looks wrong. On a hub deployment, app.deps entries carry meta.module — the case api/registry/registry.go:101-104 documents as the reason the field exists — and the fallback returns false.

What breaks

The root drops out of collectSnapshotDependencies (dependency_handler.go:1022-1025) before ReconcileResolution ever sees it, taking data.parameters with it. Boot then dies at link stage with walls of requirement X: no value available. deploymentBaselineDigest gates on the same field, so those roots also silently fall out of the baseline digest.

Observed on a Postgres-history hub deployment: repeated boot crash-loops, ten knowledge bases left unprovisioned, and an operator runbook that recovered by dropping the history schema and reinstalling dependencies — which is really this flag loss, not a history defect.

The fix

Expose the field to Lua as root. meta is user space and carries no trust, so the field is the sole authority for deployment-root status. It is optional on the way in and always present on the way out, so a writer may omit it while a reader can always carry it back.

  • utils.go — read root in luaTableToEntry, emit it in entryToLuaTable
  • types.goOptField("root", typ.Boolean) on entryType, so strict-typed Lua can touch it
  • spec.md — document the field and the read-modify-write contract

Tests

  • utils_test.go — three unit tests: the field is read, it defaults to false when absent, and it survives a full round trip
  • tests/app/src/test/registry/dependency_root_field.lua — live registry test: write, read back, rewrite through Lua, assert the root is not demoted

go test ./... is clean. Note the app-level Lua suite could not be executed to confirm the new integration test: wippy test reports "No tests found" on main for both tests/app and unrelated apps, which is a pre-existing test-discovery regression independent of this change.

Follow-up, not in this PR

Callers that construct dependency entries in Lua must write root = true for hub-installed roots to be marked. Until they do, this change is inert for those entries — it is not a regression, but the fix is only complete once the writers are updated.

wolfy-j added 2 commits August 6, 2026 23:38
luaTableToEntry read only id, kind, meta and data, and entryToLuaTable
emitted the same four. Entry.DependencyRoot was therefore invisible in
both directions, so every entry written through the Lua registry module
persisted with the flag cleared and every read-modify-write cleared it
again.

isRootDependency falls back to an empty meta.module when the flag is
absent, which hides the loss on a source-run app but not on a hub
deployment, where app.deps entries carry meta.module. There the root
drops out of collectSnapshotDependencies before ReconcileResolution
sees it, taking its parameters with it, and boot dies at link stage
with "requirement X: no value available". deploymentBaselineDigest
gates on the same field, so those roots also fell out of the digest.

Expose the field to Lua as `root`: meta is user space and carries no
trust, so the field is the sole authority for deployment-root status.
It is optional on the way in and always present on the way out, so a
writer may omit it while a reader can always carry it back.
Carrying the field is not enough on its own. Absence on an update meant
false, so any writer unaware of root status demoted a deployment root on
every rewrite -- read the entry, change one field, write it back, and the
root is gone. The demotion is silent until the next boot, where the entry
drops out of collectSnapshotDependencies and takes its parameters with it.

Absence on an update now means unchanged: the operation inherits the
stored value from the snapshot it was opened against. Only an explicit
root = false demotes. Creates are unaffected and still default to false.
@wolfy-j

wolfy-j commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Root cause confirmed against live production data

The failure was reproduced on a Postgres-history hub deployment. Its registry history contains the demotion directly. Changeset v12 is a keeper-issued entry.update on app.deps:keeper:

  • OriginalEntryDependencyRoot \303 (msgpack 0xc3, true)
  • EntryDependencyRoot \302 (msgpack 0xc2, false)

keeper:admin_scope is present in the parameter list of both the original and the updated entry. The parameter was never lost. The entry simply stopped being a root, and because meta.module = kickside/kickside is set the entryModule(entry) == "" fallback could not cover for it, so collectSnapshotDependencies skipped the entry and its parameters never reached the link stage.

The instance had been serving fine for hours — its state was already linked in memory. The next restart, for unrelated reasons, is what detonated it:

stage 'link' failed: unresolved requirements: requirement admin_scope in namespace keeper
failed: no value available: no dependency parameter found and no default value specified

Recovery was rolling the registry head back one version, to the state carrying the pre-demotion entry.

Second commit: absence on update means unchanged

Carrying the field across the boundary is necessary but not sufficient. Absence still meant false, so any writer unaware of root status demoted a root on every read-modify-write — which is exactly the shape of the update that broke the deployment above.

changesUpdate now inherits the stored DependencyRoot from the snapshot the changeset was opened against when the Lua table does not mention root. Only an explicit root = false demotes. Creates are unaffected and still default to false.

This is what makes a reboot land on correct state regardless of what the writer knows about the field.

go test ./... is clean.

@wolfy-j
wolfy-j merged commit 02cc912 into main Aug 8, 2026
4 checks passed
@wolfy-j
wolfy-j deleted the fix/lua-registry-dependency-root branch August 8, 2026 01:44
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.

2 participants