RFC-0001 — Shared Correlation Key & Completion-Event Schema

Status: Implemented (PFactory · AIFactory · TFactory emitters + CFactory collector) · Version: 1.3 · Updated: 2026-07-11 Part of the PARR spine (issue #1, #4). The product repos implement against this document.

1. Motivation

The four products — PFactory (plan) → AIFactory (act) → TFactory (verify), all observed by CFactory (cockpit) — each own one stage of one unit of work. To thread a single unit across the family (for CFactory’s observability and for an EU-AI-Act-grade audit trail), every product must agree on two things:

  1. a shared correlation key that identifies the unit of work end to end, and
  2. a normalized completion-event envelope every service emits when its stage reaches a terminal state.

This RFC specifies both. It is deliberately small and additive-friendly so a new product (or a new field) does not force a breaking change.

2. The correlation key

The correlation key is the GitHub issue number of the governed work item — the durable, human-visible artifact every stage already references.

pfactory.session_id  →  issue#  →  aifactory.task_id  →  branch / PR#  →  tfactory.spec_id

3. The completion-event envelope

A service emits one completion event when its stage reaches a terminal status. The envelope has these required fields — the RFC core plus CloudEvents-core (adopted in the v1.2 additive upgrade #466/#468 and made canonical by the #471 cutover, which removed the now-redundant legacy schema_version, event, correlation_id, and updated_at duplicates):

Field Type Description
correlation_key string The shared key (§2) — issue number, or synthetic fallback.
service string The emitting service: pfactory | aifactory | tfactory | cfactory.
task_id string The emitter’s local identifier for this unit (PFactory: session_id; AIFactory: task_id; TFactory: spec_id).
status string The terminal status this service landed on (§5).
phase string The pipeline phase the status belongs to (e.g. emit, review, qa, test).
id string Per-event idempotency key (CloudEvents id); consumers dedup on it exactly-once.
specversion string CloudEvents spec version — 1.0.
source string CloudEvents source URI-reference identifying the producer (e.g. /pfactory).
type string CloudEvents event type (reverse-DNS, e.g. io.factory.pfactory.completion).
time string ISO-8601 UTC occurrence timestamp (CloudEvents time; replaces the legacy updated_at).

A traceparent (W3C trace context) is also emitted for OpenTelemetry correlation.

Example

{
  "correlation_key": "142",
  "service": "pfactory",
  "task_id": "001-refund-flow",
  "status": "emitted",
  "phase": "emit",
  "id": "9f1c0e2a-0b3d-4a5e-8c7f-1a2b3c4d5e6f",
  "specversion": "1.0",
  "source": "/pfactory",
  "type": "io.factory.pfactory.completion",
  "time": "2026-06-04T15:14:45+00:00"
}

3.1 The optional usage block (v1.1)

A service MAY include a usage object carrying the LLM token/cost it spent on this stage, so a consumer (CFactory) can aggregate spend by service, work item and total without polling. Additive and optional — consumers MUST ignore it when absent.

Field Type Description
input_tokens int Prompt tokens (fresh, non-cached).
output_tokens int Completion tokens.
total_tokens int Convenience total.
cost_usd number Provider/estimated cost for the stage.
model string? Model id, optional.
{
  "correlation_key": "142", "service": "aifactory", "task_id": "proj:001",
  "status": "done", "phase": "act", "time": "2026-06-05T12:00:00+00:00",
  "usage": { "input_tokens": 2400, "output_tokens": 100,
             "total_tokens": 2500, "cost_usd": 1.25, "model": "claude-sonnet-4-6" }
}

AIFactory derives it from its per-task token_usage.json (already tracked). PFactory and TFactory accumulate per session/spec and emit it when instrumented (pending).

3.2 The optional injection_scan block (v1.3)

A service that ran a prompt-injection scan over untrusted content (issue text, cloned repo content, MCP responses — see the untrusted-content threat model, issue #273) MAY include an injection_scan object carrying the verdict, so CFactory can display what was and was not scanned. Additive and optional — consumers MUST ignore it when absent. Absence means no scan ran at this stage; it is NEVER interpreted as a pass.

Field Type Description
verdict string pass | flagged | skipped. flagged => the task paused to human_review (fail-closed, never silent-continue). skipped => the scan did not run; NEVER reported as pass.
reason string? REQUIRED for flagged/skipped: what was flagged, or why the scan was skipped.
scanner string? Scanner identifier, e.g. aifactory-precoder-v1.
sources_scanned string[]? The untrusted sources inspected, e.g. ["issue_text", "repo_content"].
{
  "correlation_key": "142", "service": "aifactory", "task_id": "proj:001",
  "status": "human_review", "phase": "act", "time": "2026-07-11T09:00:00+00:00",
  "injection_scan": { "verdict": "flagged", "scanner": "aifactory-precoder-v1",
                      "reason": "instruction-like payload in README.md: 'ignore previous instructions'" }
}

4. The optional correlation block

An emitter MAY include a correlation object carrying the upstream/downstream chain links it knows about, so a consumer (CFactory) can stitch the thread without a join table. All members are optional and additive:

{
  "correlation_key": "142",
  "service": "pfactory",
  "task_id": "001-refund-flow",
  "status": "emitted",
  "phase": "emit",
  "time": "2026-06-04T15:14:45+00:00",
  "correlation": {
    "session_id": "001-refund-flow",
    "issue_number": 142,
    "aifactory_task_id": null
  }
}

Known link fields (extend as the chain grows): session_id, issue_number, aifactory_task_id, branch, pr_number, tfactory_spec_id.

5. Per-service contract

Each service decides its own terminal statuses and phases; the envelope normalizes the shape, not the vocabulary. The reference (PFactory) contract:

Service service task_id Terminal statusphase Emits when
PFactory pfactory session_id emittedemit, rejectedreview a plan is governed-emitted, or rejected
AIFactory aifactory task_id e.g. merged/qa_failedact/qa a build run reaches a terminal state
TFactory tfactory spec_id e.g. triaged/triaged_empty/triager_failedtest the verdict pipeline completes

phase for an unknown status SHOULD fall back to the status string itself. Consumers MUST treat unknown status/phase values as opaque (forward-compatibility).

6. Transport

Completion events are delivered best-effort — a failing delivery MUST NEVER break the emitting pipeline.

Operators opt in per service via env. PFactory’s surface (other services SHOULD mirror the names with their own prefix):

Env var Effect
PFACTORY_COMPLETION_WEBHOOK=<url> POST the envelope to <url> on terminal status.
PFACTORY_COMPLETION_WEBHOOK_TIMEOUT=<seconds> Webhook timeout (default 5).
PFACTORY_COMPLETION_SENTINEL=1 Also write a COMPLETED.json sentinel.
PFACTORY_COMPLETION_SENTINEL_DIR=<dir> Where the sentinel is written.

Consumers MUST treat events as idempotent by the per-event CloudEvents id (#468) — a retried or duplicated delivery carries the same id and is the same event, while a legitimate re-run after handback carries a new id. The #471 cutover removed the legacy (service, correlation_key, status) dedup key (it wrongly collided on re-runs); a consumer that receives an event with no id SHOULD treat it as an ingest anomaly rather than silently fall back to the old key.

7. Versioning & compatibility

8. Reference implementation

PFactory implements this RFC today:

AIFactory and TFactory implement the emitter side against §3–§6; CFactory implements the consumer side (idempotent ingest keyed by §7).