DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-098-engine-api-boundary-closure-v0_2.md

Change Request — Engine API boundary closure — v0.2

CR number. CR-2026-098 (confirmed next-free against the record 2026-06-07; CR-2026-097 was the prior highest). Version. 0.2 Date. 2026-06-07 (v0.1) · v0.2 amendment 2026-06-07. Engine anchor. loomworks-engine, branch main, HEAD 5472335 (547233503bc4d1229a64811920fa173a77d1d0a6), working tree clean. Re-anchored 2026-06-07 (Step 0); engine unmoved since the inspections (0 commits since the anchor). All line references below are current as of this HEAD. Source. loomworks-record/investigations/loomworks-engine-api-boundary-inspection-findings-v0_1 (two read-only inspection passes), plus the Step 0 re-anchor inspection (2026-06-07). Seed alignment. Checked against loomworks-candidate-seed-v0_12 (current canonical). No conflict — these are boundary-integrity fixes. The seed's UUID-identity, OVA-authorization, and human-Operator-authority commitments are reinforced, not altered. The signup-throttle finding is deliberately NOT in this CR (it is the first piece of the two-tier signup build, sequenced after the seed and carrying an open mechanism decision). Consumer. Claude Code executing against loomworks-engine.

v0.2 changes (from v0.1) — recorded, not smoothed.


Purpose

The API boundary inspections established that the managed API is not yet the sole path to authoritative Engine state. This CR closes the four findings that are pure boundary-integrity fixes — no seed dependency, no design choices — so that the engagement-scoped surface a freely-constructed Companion would call is complete and regulated. v0.2 also folds in one bug fix (BUG1) that blocks the API-documentation effort sequenced after this CR.

Four boundary fixes plus one folded-in bug fix:

The A1–A2 ordering matters: A2's unauthenticated route is the membership-planting step in the compose-chain that makes B1 exploitable. Removing A2 de-fangs B1 even before B1 is tightened. Both are fixed here regardless.


Scope discipline

This CR removes two routes (plus A1's now-orphaned token-mint wiring), tightens two authorization sites, and fixes one schema-blocking forward-ref (BUG1). It does not refactor surrounding code, does not change the credit data model, does not alter the migration data model (only removes the now-unused HTTP entry points and their dead wiring), and does not touch the signup routes. If execution surfaces any ambiguity — a caller not anticipated here, a test that depends on removed behavior in a way the repoint does not cover, a shared helper that other live code needs, or a token-mint reference outside the migration-only sites Step 0 found — HALT and report rather than working around it.


A1 — Remove migration mint route, migration passkey ceremony, and the token-mint wiring

Rationale. The mint route (POST /admin/mint-migration-passkey-token) is an unauthenticated route that mints a first-credential bridge token; its sole caller is scripts/migrate_to_person_layer.py. The ceremony routes it feeds (/auth/migration/passkey/begin, /auth/migration/passkey/complete) exist only to redeem that token. The Phase 14 migration was a one-time event; the Operator confirms no future migrations are planned and the system is not deployed. The inspection confirmed a normal new account never touches these routes (signup issues its own credential inline, and a net-new account sets first_login_at=now at creation, making it structurally ineligible for the mint route's first_login_at IS NULL gate). These routes are therefore spent tooling carrying an unauthenticated account-takeover exposure. Step 0 further confirmed the token-mint primitive behind them is wired only in migration-only sites with no other consumer, so that wiring is dead once the routes go.

Files and sites (current @ 5472335).

Changes.

  1. Remove the three routes from migration.py. If removing them empties the router module of all routes, remove the module and its router registration in the app factory; if other live routes remain in the module, leave the module and remove only these three handlers.
  2. Remove the router's mount/registration for any removed routes in the app factory (where migration.py's router is include_router-ed).
  3. scripts/migrate_to_person_layer.py is migration tooling for a completed, non-recurring migration. Since the route it calls is being removed, the script's mint call (:86) will no longer resolve. Per Operator confirmation that no future migrations are planned: remove the script, OR if the script has other still-useful structure, remove only its call to the deleted route and mark the migration path retired with a comment. Recommend: remove the script entirely — it is dead tooling for a finished one-time migration. HALT and confirm with the Operator (via the relayed channel) before deleting the script if there is any doubt about whether it carries reusable structure.
  4. Remove the now-orphaned token-mint wiring (v0.2 — promoted from v0.1's "check for other callers"). Step 0 confirmed the primitive is migration-only with no other consumer, so all of the following are removed alongside the routes:

If Step 1 finds any reference outside these migration-only sites (contrary to Step 0's finding), HALT and report rather than removing.

Tests. Remove or update any tests that exercise the migration mint/ceremony routes. Report which test files were touched. If a removed test was the only coverage of a behavior that still exists elsewhere, flag it.

Verification.


A2 — Remove initial-contributors route; repoint test callers to the inner helper

Rationale. POST /engagements/{engagement_id}/initial-contributors is a Phase 3 legacy route (predates the person-layer model by ten days) that mints commit-authority contributors with no authentication, gated only by candidate-state plus knowledge of an engagement UUID. It has no production caller — not the frontend, Companion, CLI, or any script. Its only callers are the test harness. It was superseded by the signup → person → membership creation model. The clean close is to remove the network route and have the tests call the inner helper directly, so the unauthenticated route stops existing while test seeding is preserved.

Files and sites (current @ 5472335).

Changes.

  1. Remove the register_initial_contributors route handler (:128–160) from contributors.py. Keep the inner helper materialize_initial_contributors (:65) — it is the seam the tests will call directly.
  2. If removing the route empties the module of routes, remove the module and its registration; otherwise remove only this handler and leave the rest.
  3. Remove the route's registration in the app factory.
  4. Repoint each test caller from an HTTP POST .../initial-contributors to a direct in-process call of materialize_initial_contributors (or register_contributor, whichever the helper exposes for the per-contributor write), passing the same InitialContributorSpec entries the HTTP body carried. The seeding behavior the tests rely on must be preserved exactly — same contributors, same commit_authority, same engagement.
  5. Update the engagements.py:25 docstring to drop the reference to the removed route.

Tests. The four named test files must pass with their seeding repointed to the helper. Report the diff in how each seeds contributors. If any test was asserting something about the route itself (its auth posture, its candidate-state gate) rather than using it as setup, flag it — that test's intent changes when the route is removed.

Verification.


A3 — Add a privilege check to the /admin/* credit routes

Rationale. Five /admin/* credit routes authenticate the caller (get_current_person) but apply no privilege check, so any logged-in person can grant credits, provision, or set spend authorization. Credit is authoritative state (the first infrastructure engagement). The route docstrings already defer "role-aware admin auth" to Phase 48; this CR supplies the missing check.

Files and sites (current @ 5472335). src/loomworks/api/routers/admin_grants.py, each currently gated only by person: Person = Depends(get_current_person):

Decision (v0.2 — resolves the v0.1 HALT). The privilege model is decided: these routes require the Engine's existing Operator/founder authority — the same authority the Engine already enforces elsewhere for founder/Operator-only operations. NOT a new admin role; NOT a hardcoded UUID. Implementation constraints:

Trajectory (preserved from v0.1). The v0.1 candidates for the Operator to choose among were: (a) a platform/operator-admin designation a person must hold (the natural reading of the deferred "role-aware admin auth"); (b) restriction to the Loomworks Operator / founder authority already used elsewhere; (c) a finer split — read routes under a lighter check than the write routes. Chosen: (b), implemented as a single shared dependency precisely so that (a) or (c) remains a cheap later change. There is no remaining Operator HALT for A3 — only the conditional Step 1 HALT above (if no reusable founder/Operator dependency exists).

Tests. Add coverage: an authenticated non-privileged person is rejected (403) on each route; the privileged actor (the Operator/founder, per the chosen dependency) succeeds. Match the implemented model.

Verification.


B1 — Re-apply finalize authorization gates

Rationale. _route_finalize_project (the Companion's converse path to finalizing a candidate engagement) calls verify_project_membership then commit_engagement directly, without the two HTTP-layer gates the direct /instantiate route applies: get_committing_contributor (human actor with commit_authority=True) and assert_engagement_creator (caller is the candidate's creator). commit_engagement itself enforces agent-rejection, fail-closed step-up presence proof, and seed convergence on both paths — so this is NOT an agent commit and NOT an unauthenticated commit. The specific drop is the creator-identity and commit-authority checks: a present, authenticated, step-up-satisfied member who is neither creator nor commit-authorized can finalize via the Companion, where /instantiate would reject them.

Files and sites (current @ 5472335).

Changes. Between the membership check (:1048) and the commit_engagement call (:1092) in _route_finalize_project, re-apply the two checks the direct /instantiate route enforces, so the converse finalize path is authorization-equivalent to the direct path:

  1. Commit-authority check — equivalent to get_committing_contributor: the actor must be a human contributor with commit_authority=True. Note the converse path synthesizes an ActorRef(kind="companion", ...); finalize_project specifically uses kind="person" (per the inspection). Apply the check against the resolved person actor. Reuse the existing dependency/helper logic rather than reimplementing the rule — confirm whether get_committing_contributor can be applied in-process here or whether its in-body equivalent must be called directly (the inspection notes other converse seams re-run their gates in-process via helpers in converse.py; follow that established pattern).
  2. Creator-identity check — equivalent to assert_engagement_creator: the caller must be the candidate's created_by_person_id. The converse seam helpers already re-run assert_engagement_creator in-process at converse.py:219/297/390 for other intents; apply the same helper here.

The goal is exact parity: a caller who would be rejected at /instantiate must be rejected at converse-finalize for the same reason, with the same error semantics where practical.

Tests. Add coverage proving parity:

Verification.


BUG1 — Fix the create-from-conversation forward-ref that breaks OpenAPI schema generation

(Folded in at v0.2. This is a bug fix, not a boundary finding — hence the BUG1 label rather than an A/B index.)

Rationale. POST /me/engagements/create-from-conversation carries a handler parameter llm_client: Optional["LLMClient"] = None — a string forward-reference. LLMClient is imported only under if TYPE_CHECKING:, so the reference is unresolvable at runtime. FastAPI treats the parameter as a request input and tries to build a Pydantic schema for it; resolution fails with PydanticUserError (TypeAdapter[... Optional[ForwardRef('LLMClient')] ...] is not fully defined). FastAPI's schema build is all-or-nothing, so this single route 500s the entire OpenAPI document — GET /openapi.json returns HTTP 500, and /docs + /redoc render an error because their client-side schema fetch fails. This blocks the API-documentation effort sequenced after this CR (a schema-driven console has nothing to read), and it also blocks the OpenAPI-based route-absence verification A1/A2 rely on.

Files and sites (current @ 5472335).

Changes. Make llm_client not enter the request schema, so FastAPI no longer tries to schematize an unresolvable forward-ref. Exact mechanism is Step 1 design — this item records WHAT and WHERE, not the final line edit. Two viable shapes:

Whichever shape is chosen, llm_client must remain functional as the internal seam input it is today (the converse path threads it in); only its presence in the request schema changes. If Step 1 finds that removing it from the request signature changes the route's behavior for any live caller, HALT and report.

Tests.

Verification.


Execution sequence and halts

  1. CR number confirmed against the record — CR-2026-098 (the v0.1 placeholder is resolved; no action).
  2. Re-confirm main @ 5472335, clean tree, before starting. Step 0 (2026-06-07) re-anchored and confirmed the engine is unmoved (0 commits since the anchor); all line references hold. If the engine moves before Step 1, re-anchor the line references before editing.
  3. A3's privilege model is decided (v0.2) — no remaining Operator HALT. Implement A3 per the recorded decision (reuse the existing founder/Operator authority dependency as a single shared dependency). The only residual A3 HALT is conditional: if no existing reusable founder/Operator authority dependency is found in Step 1, HALT and report.
  4. Execute A2 and A1 (route removals + repoints + the A1 token-mint wiring removal), then B1 (finalize parity), then A3 (privilege dependency swap). BUG1 (forward-ref fix) is independent and may be done in any order — recommended early, since it unblocks OpenAPI schema generation that the A1/A2 route-absence verifications use.
  5. Halt before any push. Report what changed, which tests were touched, and the resulting test-suite status, for Operator review before the work lands.
  6. Commit and push only on explicit Operator go-ahead.

Done means


DUNIN7 — Done In Seven LLC — Miami, Florida Change Request — Engine API boundary closure — v0.2 — 2026-06-07