Skip to content

feat: portable configs — logical references via a shared per-host resolver (#20) - #46

Merged
2000game merged 5 commits into
mainfrom
feat/portable-refs-20
Jul 9, 2026
Merged

2000game merged 5 commits into
mainfrom
feat/portable-refs-20

Conversation

@2000game

@2000game 2000game commented Jul 9, 2026

Copy link
Copy Markdown
Member

Implements #20 per the architect plan: configs reference master data by name/key instead of numeric CT ids, making one config portable across instances.

Authoring surface

  • Named logical fields: ct.group({ key, groupType: "ministry_team", status: "active", campus: "mainz" }) — sugared at eval into Ref-valued groupTypeId/groupStatusId/campusId. Declaring both forms throws.
  • ref.* helper for inline positions: ref.campus("mainz") in dynamic-ruleset var values; groupType: "<name>" for group_type_role domains.
  • Raw numbers pass through untouched everywhere (escape hatch); feat: group ↔ campus assignment + deliberate group field coverage (#21) #43's campus: rejection guard superseded by real support.

Resolution model

  • Eval is host-agnostic (Ref sentinels only, no network). Plan-time resolves against desired ∪ state ∪ lazily-cached live catalogs (/campuses, /group/grouptypes, /group/memberstatus, /group/roles) by slug/exact-name with ambiguity detection; unknown/ambiguous refs throw (config error — no partial apply).
  • Same-run-created targets become pending markers rendered as <campus:mainz (created this apply)>, re-resolved at apply time against post-execute state — including refs inside dynamic rulesets routed via synthetic-field writes.
  • One resolver instance shared by buildPlan + buildPermissionPlan under their Promise.all.
  • Gated: group_role (group, role)→pairing-id lookup has no confirmed API source — DSL accepts the form but resolver rejects with a clear "pass a numeric id (see feat: permissions ergonomics — domainId by reference, grant adoption, catalog lifecycle #25)" error, behind a pluggable seam.

Acceptance

  • examples/portable.config.ts: zero numeric ids.
  • Two-host test: one config produces valid host-specific plans against two different states/catalogs.
  • Documented assumption: /group/memberstatus rows carry name (no fixture/schema available); mis-shape falls through to a hard error, never mis-resolves.

Rebased onto main post-#45; all 14 grant-adoption round-trip tests pass against the new desiredTuples path.

Verification: 343 passed / 4 skipped, typecheck + lint clean.

Closes #20.

2000game added 5 commits July 9, 2026 09:14
#20)

Add Ref sentinels + ref.* helper (src/resolve/refs.ts) and a per-host Resolver
(src/resolve/resolver.ts) sourcing ids from managed desired∪state (pending for
same-run targets) then live catalogs (campus/group-type/group-status/role-def),
throwing on unknown/ambiguous. DSL sugars campus/groupType/status into Ref-valued
id fields and accepts groupType (group_type_role) + gated group+role (group_role).
Wire the resolver into buildPlan (resolution pass before computePlan), permission
domainId resolution, apply-time pending re-resolution, and pending plan rendering.
refs.ts (helper/guards/deepMapRefs/collectRefs), resolver.ts (state/catalog/
pending/ambiguous/unknown/gated group_role + resolveValue caching + apply-time
re-resolution), DSL permission logical forms, and the two-host acceptance test.
Convert examples to the named/ref forms (numeric escape-hatch notes kept); add
examples/portable.config.ts (zero numeric ids). Update permissions.md,
dynamic-groups.md, blueprints.md, README; mark #20 shipped in the runbook and
note the gated group_role part (#25).
…#20)

A same-run reference embedded in a dynamic ruleset's var value was applied via
applySyntheticFields, bypassing body re-resolution — the pending sentinel would
leak into the ruleset PUT. Re-resolve the whole change set up front so both the
write body and synthetic-field writes see real ids. Lock it with a test.
…r referencers

A pending ref names a same-run resource, but tier ordering alone doesn't
put the target first when referencer and target share a tier (a group's
ruleset ref.group()-ing another group applied in declaration order and
could never converge). buildPlan now injects a dependsOn edge from the
referencer to each pending target so orderKeys sequences them; the
pending-unresolved apply error no longer misdirects to tier ordering.
Flagged by the PR #46 review with an empirical repro; test locks the
create-before-ruleset-PUT order and the resolved id.
@2000game

2000game commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Review verdict was REQUEST_CHANGES with one MEDIUM finding (everything else verified clean empirically): same-tier pending refs had no ordering guarantee — a group whose ruleset ref.group()s another same-run group applied in declaration order, threw mid-apply with a misleading "its tier should have applied first" error, and could never converge.

Fixed in 30b59de: buildPlan now injects a dependsOn edge from the referencer to every pending-ref target (after the resolution pass, before computePlan), so orderKeys topologically sequences the target first — cross-tier cases are unaffected (edge is redundant there), and mutual same-tier refs now surface as orderKeys' existing cycle error instead of a runtime wedge. The apply-time pending-unresolved error message now correctly points at an earlier failed create rather than tier ordering. Locked by a test verified to fail without the fix (create-before-ruleset-PUT order + resolved id in the PUT body).

343 → 344 tests passing; typecheck + lint clean. Merging.

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.

feat: portable configs — logical references instead of numeric CT ids (shared resolver)

1 participant