DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path standing-notes/loomworks-scoping-what-happens-to-a-grant-when-its-engagement-is-unmade-v0_2.md

Loomworks — Scoping Note: What Happens to a Grant When Its Engagement Is Unmade — v0.2

Version: v0.2 Date: 2026-08-22 Status: Scoping note. Names a live defect and the doctrinal question underneath it. Rules neither. Markdown primary — the consumer is Claude Code and change-request drafting. Changes from v0.1. §6.1 is answered and it bounds the question — the collision is specific to agent_sessions / agent_lifecycle_events, not a property of operational tables generally (§6a). The check also found the opposite failure beside it: credit_flows survives a discard as a dangling reference (§6b), so the system has two operational tables behaving in opposite ways and neither behaviour was chosen (§6c). §5's options gain a live precedent, since credit_flows is already living one of the answers. Two points about where the reasoning belongs are added at §2a. v0.1 stands as sibling. Occasion: CC's §4 and §6.1 checks against loomworks-candidate-discard-and-the-record-v0_2, 2026-08-21 and 2026-08-22, both run empirically inside rolled-back transactions at engine 143fd6b. Reads with: foray-reference/loomworks-candidate-discard-and-the-record-v0_2; standing-notes/loomworks-standing-note-two-records-and-what-crosses-between-them-v0_1 (STANDING); change-requests/cr-2026-231-w6-writing-the-grant-into-the-record-v0_3 §5, §6; engagement/candidate_discard_policy.py.


Plain-language summary

Discarding a candidate engagement that ever ran an agent now fails with a database error. Not in an unusual case — in every case, because every grant writes a row recording that it was granted, and that row points at the session the discard is trying to delete.

The immediate fix is one line of schema. Which line depends on a question nobody has answered, and the question is bigger than the defect.

Candidate engagements are deletable because nothing has been committed yet — that carve-out is ruled and built. Grant records are append-only because they are the audit trail of what the system did — that was ruled yesterday. The two rules now meet, and they disagree. A grant refused to an agent working on a candidate is a governance act that genuinely happened, and the candidate being unmade later does not unmake the refusal.

Three schema options. Each is a different answer about how far the pre-commitment carve-out reaches. This note lays out what each one commits to and rules none of them.

The follow-up check narrowed it and found something beside it. Credit spending was checked on the same question and turns out to be safe — but safe by accident. It has no link to the engagement at all, so a discard leaves the spend records standing, pointing at an engagement that no longer exists. So two operational tables behave in opposite ways when a candidate is unmade: one blocks the erasure, one is orphaned by it. Neither behaviour was decided.


1. The defect, verified rather than reasoned

Confirmed empirically, not traced: a real session minted against a candidate-state engagement, its granted lifecycle row written by the ordinary mint path, then discard_candidate_engagement called unmodified.


IntegrityError - ForeignKeyViolationError:
update or delete on table "agent_sessions" violates foreign key
constraint "fk_agent_lifecycle_events_session" on table
"agent_lifecycle_events"

No unusual sequence is required. mint_session writes a granted row on every grant, so any candidate that ever held an agent session is undiscardable today. The session-holding path is shaping — the one path H0 governs.

Scope of exposure: small in practice, because agent_sessions held zero rows at the H0 close and agent-room traffic is four calls in the engine's history. The defect is certain and the incidence is currently near zero. That is a reason to rule it carefully, not quickly.

2. What the FK topology actually is

The earlier framing asked whether agent_lifecycle_events.engagement_id was an undeclared foreign key. It is not a foreign key at all — plain column, no constraint targeting engagements.

The dependency is transitive:


agent_lifecycle_events.session_id
  → agent_sessions.id                    (fk_..._session, ON DELETE NO ACTION)
     → agent_sessions.engagement_id
        → engagements.id                 (discard policy: delete)

So there was never a missing policy entry. There is no column on agent_lifecycle_events to declare a policy against. Adding one would have been the forced policy the policy file's own header warns about — worse than an undeclared FK, because it passes the fence.

2a. Where the reasoning belongs, and the boundary that already exists

Two points about placement, because the fix and the reason can end up in different files.

The ON DELETE clause is not where the thinking should live. All three options at §5 are edits to fk_agent_lifecycle_events_session — a constraint on the newest table in the chain. But the discard policy is where this project has ruled that decisions get declared: the declaration is where the thinking happens. Today the agent_sessions entry declares delete with a reason about session residue and says nothing about what that delete does downstream. Whatever is ruled, the reasoning belongs in that policy entry as well, not only in a schema clause — otherwise it repeats the pre-commitment carve-out's own failure, where a ruling lives in a docstring and no reader of the record can find it.

A boundary already exists one link further along, and it is the right one. agent_sessions.agent_identity_idagent_identities, and CR-2026-227 item 4's policy comment already states it: what does NOT follow is the agent_identity the session references. Identity is permanent and platform-wide — it survives every engagement, which is the point of it.

So the carve-out already stops somewhere. It reaches the session and not the identity. The question is not whether it has a boundary but where the next one sits — between the identity and the session, or between the session and the record of the grant.

3. The fence is correct, and its guarantee is narrower than its name

test_every_referencing_fk_has_a_declared_policy scans:


WHERE tc.constraint_type = 'FOREIGN KEY'
  AND ccu.table_name = 'engagements'
  AND ccu.column_name = 'id'

Direct references to engagements.id. agent_lifecycle_events has no such FK — its two point at agent_identities.id and agent_sessions.id. The fence did exactly what it is built to do.

Third instance of one pattern in this arc, and worth recording as such:

| Contract | Guarantees | Does not see | |---|---|---| | Discard policy fence | Every direct FK to engagements.id has a decision | Transitive paths through another table | | Unsessioned baseline | Every ActorRef construction is classified | References passed rather than constructed | | FORAY census | Marker strings found by grep | Sites versus strings, collapsed multi-emits |

The general form: a source-contract test guarantees exactly what it scans, and its name always sounds broader than its scan. Each of the three was written well; each has a boundary; each boundary was found by building, not by reading. Whether the discard fence should scan transitively is a question this note raises and does not answer — widening it would pull in a large indirect closure, and that is a design decision.

4. The doctrinal question

Two rules meet and disagree.

The question: does the pre-commitment carve-out reach the operational record, or stop at Memory?

Why the answer is not obvious. The carve-out's reasoning is that nothing was committed, so nothing is owed. That is persuasive about contents — a candidate's draft assertions are not knowledge anyone relied on. It is much less persuasive about acts. A grant issued, and especially a grant refused, is a thing the system did, at a time, to an identity, for a reason. The candidate being unmade afterwards does not unmake the refusal.

The refusal is the sharpest case and should be the test of any answer. CR-2026-231 exists because a refusal was the least recorded event in the system and the one an audit most wants — a refusal is the moment governance actually did something. An answer that deletes refusals because the engagement was later discarded gives back the thing W6 was built to obtain.

A second consideration, pointing the other way. A candidate is discardable partly so a person can abandon an exploration without leaving a permanent trail. If lifecycle rows survive, a discarded candidate leaves durable evidence that it existed and that agents worked on it. That is a real privacy-shaped property and it deserves to be weighed, not assumed away. It is the same tension the anchor asymmetry already raises — a FORAY anchor outlives local deletion by design.

5. The three options and what each commits to

5a. ON DELETE CASCADE — the carve-out reaches everywhere

Lifecycle rows die with their session, which dies with the candidate.

5b. ON DELETE SET NULL — the act survives, its subject does not

The lifecycle row remains; session_id becomes null.

5c. ON DELETE RESTRICT — the operational record wins

The discard refuses while lifecycle rows exist.

Not an option: adding a discard policy entry. There is no column to declare it against (§2), and a forced policy passes the fence, which the policy header names as worse than nothing.

6. What the check answered

6a. The question is bounded — this is not a general property of operational tables

All 29 delete entries in CANDIDATE_DISCARD_POLICIES were tested against the standing note's §3 boundary. agent_sessions is the only one that is a substrate act. Everything else is Memory-shaped content under the same pre-commitment carve-out, or scoped and derived residue that legitimately follows its engagement out — jobs, views, config, workspace, tags.

Incoming references were then checked for all 29, transitively — the direction the fence does not scan:

So agent_sessions is the only table in the list that is both a substrate act and carries a live transitive dependency. The ruling is narrower than it looked when the question was posed.

6b. credit_flows survives a discard, orphaned

Verified empirically, same method: an issuance and a full three-row consumption spend minted against a candidate, the candidate discarded, the rows queried afterwards.

The discard succeeded. All four flow rows survived untouched. The engagement row was gone. Querying extra_metadata->>'engagement_id' on the survivors returns the id of an engagement that no longer exists.

credit_flows is not in the discard policy at all, has no engagement_id column and no foreign key to engagements — the engagement id lives only inside a JSONB field with no referential integrity. The policy loop never touches it because there is structurally nothing to find.

This is the opposite failure from agent_sessions. There, the act blocks the erasure. Here, the act survives the erasure as a dangling reference.

Worth naming: that JSONB engagement id is the N4 situation domestically — a pointer that leaves the local record's holdings, with nothing marking it as such. FORAY now permits and labels such references. Loomworks makes them silently.

6c. Two accidents, no position

credit_flows already behaves the way the append-only rule would require, and it does so because someone once omitted a column — not because anyone decided. agent_sessions blocks erasure because of a foreign key's default, not because anyone decided.

The system currently has no position on what happens to a substrate act when its engagement is unmade. It has two accidents that point in opposite directions.

This makes credit_flows the live precedent for §5b and §5c: it is already living the outcome those options produce, and it shows the cost plainly — an orphan with no record of what orphaned it. Which is the strongest argument for ruling §6.2 alongside the option, because an erasure that accounts for itself is a different thing from an erasure.

7. What any answer must also decide

  1. Does the pattern extend past what was checked? §6a covers the discard policy's 29 tables. It does not cover operational tables reachable by other deletion paths, if any exist.
  2. Does the discard get recorded at all? Open at loomworks-candidate-discard-and-the-record-v0_2 §2. If a discard writes an operational-record row naming what it removed, 5a becomes far more defensible — erasure with an account of itself is different from erasure. The two questions should be ruled together.
  3. Should the discard fence scan transitively? (§3.) A wider scan finds the next instance of this before it is a defect; it also pulls in a large indirect closure.
  4. What does a refusal on a candidate mean under whichever answer is chosen? The test case for all three.

8. Sequencing

The defect is live and the incidence is near zero. Nothing forces a fast ruling, and the arc's own record argues against one — the last three times a schema shape was chosen quickly, the halt came at pre-flight.

The prerequisite is met. §6a and §6b bound the question: it is specific, not general, and the answer does not have to cover every operational table.

Recommended order now: rule §7.2 — whether the discard records itself — together with the option at §5, because §6c shows why. credit_flows already produces §5b's outcome and demonstrates its cost: an orphan with nothing recording what orphaned it. An erasure that accounts for itself is a different thing from an erasure, and that difference changes which option is defensible.

9. What this note does not do

It does not choose among 5a, 5b and 5c. It does not add a discard policy or alter any constraint. It does not answer whether the carve-out reaches the operational record. It does not widen the fence. It does not fix credit_flows' orphaned rows or its unlabelled JSONB reference — §6b records them and rules nothing. And it does not treat the privacy consideration at §4 as settled either way.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Scoping Note: What Happens to a Grant When Its Engagement Is Unmade — v0.2 — 2026-08-22