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
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
- 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.
- workplan frontmatter
- Ownership. A row is source-owned iff
workplan_dependencies.source_owner = 'forge:<repo-slug>'(additive, nullable column, migrationb3c4d5e6f7a8) and its from-workplan belongs to that repository.source_commitrecords 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. - 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). - 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. - 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 inunresolved_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). - 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.
- 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.
- Repeat noop. Owned rows are not rewritten when unchanged, so the same
commit received twice reports
status: noopwith zero created/removed edges. (unresolved_edgesis 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_idambiguity across workplans is the realistic case. - Not deployed; no evidence of scheduled freshness. T05 stays in progress.