Completion-event envelope
Conforms to RFC-0001 — the canonical shared correlation-key + completion-event schema (Factory PARR-spine epic #1). TFactory emits this on terminal Triager status (#198/#211).
When a unit of work reaches a terminal state, TFactory emits a normalized completion event so the watcher (CFactory) consumes one schema across all services (AIFactory · PFactory · TFactory).
Correlation key
Per RFC-0001 §2, the shared key is correlation_key — the GitHub issue
number rendered as a string, with a synthetic tf-<spec_id> fallback so it
is never null. TFactory reads the issue number from status.json or
context/source.json (issue_number / correlation_id, populated by the
PFactory pickup contract, epic #193); absent one, it falls back to the synthetic
key. The legacy int field correlation_id is retained as a backward-compat alias.
Schema
The six RFC-0001 core fields, plus the optional correlation chain block, plus
additive TFactory detail (RFC §7 permits extra fields):
{
// RFC-0001 core (required)
"correlation_key": "412", // issue# as string | "tf-<spec_id>" — never null
"service": "tfactory", // aifactory | pfactory | tfactory
"task_id": "001-pricing", // emitting service's task id
"status": "triaged", // service-native terminal status (verbatim)
"phase": "test", // pipeline phase (falls back to "test")
"updated_at": "2026-06-04T16:29:58+00:00",
// RFC-0001 §4 optional chain block (upstream/downstream links)
"correlation": {
"issue_number": 412, // int | null
"spec_id": "001-pricing",
"branch": "feat/x",
"pr_number": 88
},
// Additive TFactory detail (+ #85/#198 backward-compat fields)
"schema_version": "1.0",
"event": "completion",
"correlation_id": 412, // legacy alias of correlation_key (int | null)
"project_id": "demo",
"spec_id": "001-pricing",
"outcome": "success", // normalized coarse outcome (see below)
"repo": "owner/name",
"branch": "feat/x",
"pr_number": 88,
"result": { "committed_count": 3, "flagged_count": 1, "rejected_count": 2,
"verdicts_count": 6, "dedup_collision_count": 0 },
"emitted_at": "2026-06-04T16:30:00+00:00",
// RFC-0001a evidence block (always present; see "Evidence gate" below)
"evidence": { "proof_kind": "tests", "verdicts": 6,
"accepted": 3, "flagged": 1, "rejected": 2 }
// "halt_reason": "no_evidence: verify produced no verdicts" // only when gated
}
Consumers that only need the spine read the six RFC core fields + correlation;
the additive fields are ignored by RFC-conformant consumers (RFC §7).
v1.2 — CloudEvents alignment + idempotency + trace context (#282)
schema_version is now 1.2. The following ride additively alongside
everything above (nothing removed — parity with AIFactory’s #466 envelope, and
validated by apps/backend/contracts/completion-event.schema.json):
{
"id": "9f1c…-uuid4", // per-event idempotency key — consumers
// dedup on this; stable across #281 relay
// re-delivery (the persisted row is resent)
"specversion": "1.0", // CloudEvents core
"source": "/tfactory", // override: TFACTORY_EVENT_SOURCE
"type": "io.factory.tfactory.completion",
"time": "2026-06-04T16:29:58+00:00", // = updated_at (occurrence time)
"traceparent": "00-<32hex>-<16hex>-01" // W3C trace context (OpenTelemetry)
}
schema_version has one source of truth (#360 / #363)
schema_version previously lived in two places that had to agree but had no
mechanism forcing them to: the vendored JSON schema’s $id
(completion-event-<version>.json in
apps/backend/contracts/completion-event.schema.json — the published contract the
sibling factories validate against) and a Python literal the Triager stamped onto
every event. Bump one without the other and a consumer sees a schema_version that
contradicts the $id it validated — silently.
apps/backend/agents/completion_schema.py is now the single source of truth: it
parses the version from the schema $id at import time, and both apps/backend (the
Triager, producer) and apps/web-server (the relay, via the shared agents package)
read that one constant. There is no second literal to forget. The version becomes a
function of the published contract — change the schema $id and the runtime follows.
tests/test_completion_schema_version.py asserts the schema $id, the schema
title, and the runtime constant all report the identical version, so the contract
can no longer silently drift.
outcome mapping (normalized across services)
| outcome | meaning | TFactory terminal status |
|---|---|---|
success |
work completed with usable results | triaged |
empty |
completed, but nothing actionable produced | triaged_empty |
failure |
terminated without usable results | triager_failed (+ any *_failed / stuck) |
Evidence gate (RFC-0001a)
A verify may only report a success outcome if it produced real evidence. A
triaged status with zero verdicts evaluated nothing, so reporting it green would
mislead a consumer into treating an empty run as a clean pass.
The Triager (_build_completion_envelope) therefore downgrades a triaged status
to outcome="failure" with halt_reason="no_evidence: verify produced no verdicts"
unless the run produced actionable evidence — any one of:
verdicts_count > 0(the Evaluator emitted at least one verdict), orcommitted_count > 0(at least one test accepted), orflagged_count > 0(at least one test flagged).
Two deliberate boundaries:
- The gate only rewrites the normalized
outcome. TFactory’s internalstatusis left untouched — its state machine and the hand-back loop read it verbatim. - All-flagged is not a failure. A
flagmeans “needs human attention” by design and drives the hand-back loop, which is a valid non-failure outcome; a run with only flagged tests still reportsoutcome="success".
Every envelope additionally carries an evidence block so a consumer can see the
counts the gate was decided on:
"evidence": {
"proof_kind": "tests", // TFactory's evidence is the test verdicts
"verdicts": 6, // = verdicts_count
"accepted": 3, // = committed_count
"flagged": 1, // = flagged_count
"rejected": 2 // = rejected_count
}
When (and only when) the gate fires, the envelope also carries
"halt_reason": "no_evidence: verify produced no verdicts".
Channels
Both are opt-in and best-effort — a missing/failing target never affects the pipeline (consistent with the no-automatic-side-effects policy). The same envelope is sent on both.
| Channel | Enable | Behaviour |
|---|---|---|
| Webhook | TFACTORY_COMPLETION_WEBHOOK=<url> |
POST the envelope as JSON (timeout TFACTORY_COMPLETION_WEBHOOK_TIMEOUT, default 5s) |
| Sentinel | TFACTORY_COMPLETION_SENTINEL=1 |
write findings/COMPLETED.json a same-host watcher can stat |
Port map (PARR spine)
TFactory’s default web-server port is 3103. Canonical local map (see the
Factory port map):
AIFactory 3101 · TFactory 3103 · CFactory 3110/3111 · PFactory 3114/3115
(PFactory moved to its own pair, freeing 3102).
Implementation
apps/backend/agents/triager.py — _build_completion_envelope() builds the
envelope; _notify_completion() emits it on terminal status. Tests:
tests/test_triager_completion_webhook.py.