state-hub/docs/forge-dependency-edge-receiving.md
tegwick c287ed8259 Preview source-owned dependency edges in the projection dry run (STATE-WP-0094-T05)
diff_against_hub accepts hub_edges/edge_targets and reports edges to create,
remove, retain, unresolved and external, using the same pure planner that
receiving now calls (plan_source_edges + classify_source_edges). The classifier
works on a row list, fixing a collapse where an external duplicate of an owned
edge key could hide the owned row from removal. 7 new pure tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 692292@bnt-lap001
Assistant-Session: 89c7d8e8-e1b3-47eb-9411-a0ab227fca2f
2026-10-04 13:19:23 +02:00

5.7 KiB

Forge dependency-edge receiving (STATE-WP-0094-T05)

Status: source implementation landed 2026-10-04; not deployed. Deployment and any receiving schedule need separate founder authorization.

Problem

docs/owner-return-handoff.md ("T05 graph freshness assessment") records that normal Forge receiving (api/services/forge_projection.py) did not derive depends_on edges or reconcile workplan_dependencies. Only C-20 created edges, from a workstation checkout, and nothing ever removed an edge whose declaration disappeared. Adding a projection that "replaces every dependency row" would delete externally authored relations, so receiving needs an explicit ownership contract first.

Contract

  1. Source. Edges are derived only from the immutable commit that receiving already pins (expected_commit = derived commit), from the same files as the rest of the projection:
    • workplan frontmatter depends_on, depends_on_workplans, depends_on_tasks - split like C-20: ids ending -T\d{2,3} are task targets (starts_after), all others workplan targets (blocks);
    • task-block depends_on - the waiting task is the from side (from_task_id). As in C-20, task-block edges of closed workplans (finished/archived) and closed tasks (done/cancel) are history and not desired.
    • A bare T## target is qualified with the declaring workplan id.
    • Bound historical workplans (historical_preservation) declare no edges.
    • A declaration that is a mapping or nested list refuses the whole derive (ForgeDeriveError); it is never dropped.
  2. Ownership. A row is source-owned iff workplan_dependencies.source_owner = 'forge:<repo-slug>' (additive, nullable column, migration b3c4d5e6f7a8) and its from-workplan belongs to that repository. source_commit records the commit that introduced it. Receiving creates rows only with that owner and removes only rows with that owner. Rows with NULL or any other owner (API, MCP, C-20 fix path, other tools) are externally authored and are never modified or removed.
  3. Already present externally. When a declared edge already exists as an external row (same from workplan, from task, target and relationship), it is preserved, not adopted, and reported in external_edges. Adoption is a deliberate future decision (see risks).
  4. Target resolution (cross-repository). Fleet-wide, excluding projection-retired workplans and their tasks: workplans by ADR-007 derived UUID or record-id slug; tasks by derived UUID or record_id. Exactly one candidate resolves. Targets created in the same receipt resolve, because resolution runs after the workplan/task writes are flushed.
  5. Missing / ambiguous / invalid targets. Zero candidates = missing, more than one = ambiguous (candidates listed; no winner chosen), a self edge = self, a waiting task the hub lacks = from-task-missing. Each is recorded in unresolved_edges; nothing is silently dropped. The receipt itself still applies (a target in a not-yet-received repository must not block the source repository's workplan projection).
  6. Removal. Only source-owned rows whose key is no longer declared are removed - including rows of workplans retired in the same acknowledged receipt. If the from side (workplan or waiting task) has any unresolved declaration, its owned rows are retained and a note is emitted, because the unresolved target may be one of them.
  7. Transactional failure. Edge reconciliation runs inside the same session as the workplan/task projection and flushes at the end. Any error propagates and the endpoint commits nothing; a refused receipt returns before edges are touched.
  8. Repeat noop. Owned rows are not rewritten when unchanged, so the same commit received twice reports status: noop with zero created/removed edges. (unresolved_edges is still reported every time.)

Outcome schema additions (state-hub.projection-reset.v1): created_edges, removed_edges, unresolved_edges, external_edges lists and their counts.

Dry-run preview

diff_against_hub(..., hub_edges=[...], edge_targets={...}) previews edges with the same pure planner receiving uses (plan_source_edges + classify_source_edges). hub_edges are the rows whose from-workplan belongs to the repository (with source_owner); edge_targets maps a target record id to its fleet-wide non-retired candidate ids. A target the hub lacks but this projection declares resolves to the id the receipt would create. The diff gains edges (create, remove, retain, unresolved, external) and edge_counts; only create/remove make it non-clean. Without hub_edges the output is unchanged (no edges key).

Duplicate keys: several rows may share one edge key (an owned row plus an API-authored duplicate). The classifier works on the row list, so an external duplicate can neither hide an owned row from removal nor turn a satisfied declaration into an external report (fixed 2026-10-04; the first implementation keyed rows by edge key and let the last row win).

Coexistence with C-20

C-20 keys existence on (from task, target, relationship) regardless of owner, so it does not duplicate a source-owned edge; edges its fix path creates are external to receiving. Existing hub edges therefore stay external until an explicit adoption decision.

Open risks

  • Pre-existing C-20/API edges are never adopted, so a removed declaration does not remove an edge C-20 created earlier; adoption needs its own decision.
  • Ambiguity by slug for workplans is mostly prevented by the global slug index; task record_id ambiguity across workplans is the realistic case.
  • Not deployed; no evidence of scheduled freshness. T05 stays in progress.