Skip to content

SP-1.5 seam-defect — agent:-prefix mismatch breaks the C2↔I1 join

REMEDIATED — GREEN (2026-05-18). Plan owner chose Option A (probe/prompt-side; principal := agent:petrova-cairnet-bridge). Corrective round CR1–CR3: CR1 (pebble d6b244e) added the missing cross-repo POST→GET seam test and empirically proved the recorded value is agent:petrova-cairnet-bridge (real auth + cairn POST + Postgres; bare form matches zero stones); CR2 (eva-hq 7fe44dc) set the prompt producer-principal value to the prefixed form (owner/producer split preserved); CR3 (petrova-codes 6110836) realigned fixtures + added a contract-pin test locking the prefixed literal and its %3A URL encoding. The terminal whole-seam gate round 2 returned GREEN: SC-1 delivered end-to-end, C1/C2/C3/I1/I2/I3 each closed, the agent:-prefix join and the %3A round-trip verified from source, CR1–CR3 spec-clean, regression clean (only the 4 known pre-existing failures, base identity proven). Nothing merged at time of this note; all work on the four feature branches awaiting branch-finishing.

Tracked next-round residuals (MR-2 — next round’s input, NOT bolted onto this GREEN round):

  1. eva-hq prompts/petrova-wire-rocky/body.md lines ~72 & ~328: the human-facing sc-2 verification curl example uses ?agent_id=/&since=, but Pebble’s stones-GET accepts only ?agent= (no ?since=; cairn.py:250). A literal sc-2 curl would hit the unknown-param-ignored path → unfiltered listing → spurious pass: a lying verification example (same defect class as the original SP-1 seam-defect, but in a diagnostic example, not the load-bearing probe — rocky.ts:91 correctly uses ?agent=). Doc-correctness fix: align the sc-2 example to ?agent= and drop the &since= (probe windows client-side).
  2. airlock scripts/seed-petrova-cairnet-bridge.ts header doc-comment lists only cairn:emit+knowledge:register; the executable buildBridgeClientSpec() correctly returns all three incl. cairn:read. Doc-comment nit; executable spec is correct and unit-tested.

SP-1.5 seam-defect — agent:-prefix mismatch breaks the C2↔I1 join

Section titled “SP-1.5 seam-defect — agent:-prefix mismatch breaks the C2↔I1 join”

All five SP-1.5 tasks (R1–R5) individually passed implement → spec-review → code-quality-review (R3/R4/R5 through multiple review rounds that caught and closed real per-task defects). The mandatory terminal whole-seam gate then found that the assembled cross-repo seam still does not deliver SC-1 end-to-end, because of a join no single task could see.

Verdict: SEAM-DEFECT-FOUND. Nothing is merged; all work is on the four feature branches (feat/cairn-idempotency, feat/petrova-cairnet-bridge-client, feat/cairnet-seam-sp1, feat/petrova-wire-rocky-deterministic); no repo’s main is affected. Branch-finishing was not invoked and SC-1 success is not claimed. Per MR-2 and the plan’s terminal clause this is the next round’s input, not an R6 bolt-on.

The defect — the C2↔I1 load-bearing join

Section titled “The defect — the C2↔I1 load-bearing join”

The SP-1.5 binding decision (principal := the OAuth client_id petrova-cairnet-bridge, no -001) was applied coherently across R1/R2/R3/R4/R5. But it rested on an unverified assumption: that Pebble records an emitted stone’s agent_id as the bare introspected principal. It does not.

Source trace (re-derived from primary source at the terminal gate, not from task reports):

  1. airlock R2scripts/seed-petrova-cairnet-bridge.ts:55: clientId: "petrova-cairnet-bridge", scopes ["cairn:emit","cairn:read","knowledge:register"]. Bare client_id.
  2. Pebble R1src/pebble/api/middleware/auth.py Bearer block: sub = body.get("sub") or body.get("client_id") → for a client_credentials token, sub = "petrova-cairnet-bridge"; metadata["agent_id"] = "petrova-cairnet-bridge". Correct.
  3. Pebble — the breaksrc/pebble/api/routes/cairn.py:148-154 _principal_triple(): binding = user.metadata.get("agent_id")return (f"agent:{binding}", ...). The POST /stones handler (cairn.py:321,339) persists the stone with agent_id = "agent:petrova-cairnet-bridge" (the request-body envelope agent_id is correctly ignored — cairn.py:312 — so the petrova-codes envelope value is moot; identity is principal-derived and gets the agent: prefix).
  4. Pebble stones-GET filtersrc/pebble/infrastructure/services/cairn_service.py:174-175: if agent: stmt = stmt.where(CairnStoneModel.agent_id == agent) — exact equality, no prefix normalization.
  5. petrova-codes R3 probecli/src/probes/rocky.ts:89: ?agent=${ev.cairnet_agent_id} where ev.cairnet_agent_id = petrova-cairnet-bridge (bare, per the eva-hq R5 prompt’s <<CAIRNET_PRODUCER_PRINCIPAL>>).

Result: stone stored under agent:petrova-cairnet-bridge; probe asks ?agent=petrova-cairnet-bridge; WHERE agent_id == 'petrova-cairnet-bridge' returns 0 rows. The rocky probe always reports total = 0failing on a perfectly-wired repo. SC-1 link 3 (and transitively the §3 join, the effective behavior of I1/I2, sc-2/sc-3) breaks.

Why no task caught it: R1’s bearer test asserts metadata['agent_id'] at the middleware boundary (fixture sub: "stratt-hq-bridge-001") and never crosses _principal_triple; R3 tests mock Pebble responses; R5 is a prompt. The agent: prefix is introduced entirely inside Pebble’s cairn.py and is asserted by no cross-repo test.

  • C1 / C3 (Pebble Bearer auth): CLOSED. RFC7662, basic-auth, fail-closed, issuer-checked, JWT-shape-gated; session/apikey paths untouched; client_credentials resolves agent_id from client_id.
  • C1↔R2 join: CLOSED. R1 parses scopepermissions+scopes; R2 supplies cairn:emit+cairn:read; both require_permission gates pass; R1 does not depend on airlock-custom metadata ({} is fine — agent_id derives from sub/client_id).
  • C2 (intrinsic emit, R4): CLOSED as written — unconditional rocky emit after all refusal gates, deterministic emitted_atfirst_emitted_at, ANCHOR_REWRITE_INVARIANT (no silent drop), queued path still anchors, posts Bearer to the auth-gated /api/cairn route (connects to R1’s path).
  • ★ C2↔I1 (the §3 join): NOT CLOSED — the agent:-prefix mismatch above. THE defect.
  • I1 / I2 (probe↔Pebble contract): contract-shape CLOSED (?agent= is the real param — cairn.py:250; stoneType/ createdAt are the real _stone_to_dict keys — cairn_service.py:71-77; no ?since=; client-side createdAt window) but functionally void because the ?agent= value can never match a recorded stone.
  • I3 / R5: prompt internally consistent and matches R3’s ?agent= param name, but instructs the bare principal Pebble does not record. R5 faithfully implements the settled contract; the settled contract is wrong about what Pebble records.

Corrective direction (next round input — pick ONE canonical end)

Section titled “Corrective direction (next round input — pick ONE canonical end)”

Do not patch in two places. Two options:

  • A — probe/prompt/evidence side (recommended; smallest, no code change, preserves Pebble’s agent/human namespacing doctrine). Change <<CAIRNET_PRODUCER_PRINCIPAL>> in eva-hq/prompts/petrova-wire-rocky/body.md (rocky evidence cairnet_agent_id, stones-GET examples, sc-2 curl) to agent:petrova-cairnet-bridge. petrova-codes R3 passes ev.cairnet_agent_id through verbatim — no code change. Add one cross-repo integration test: POST a stone as the bridge principal, then GET ?agent= with the documented value, assert ≥1 match.
  • B — Pebble side. Strip/skip the agent: prefix for machine bridge principals in _principal_triple so recorded agent_id == "petrova-cairnet-bridge". Larger blast radius — touches Pebble’s identity-provenance doctrine for all agent writes. Not recommended unless the doctrine explicitly wants bridge clients un-prefixed.

The absence of a single POST-then-GET-by-?agent= cross-repo test (with the exact documented value) is the structural root cause of this slipping past five green tasks; that test is mandatory in the next round whichever option is chosen.

Residual observations (non-blocking, carried forward)

Section titled “Residual observations (non-blocking, carried forward)”
  • Pebble _introspect_opaque sync httpx.post blocks the async event loop — fail-closed, faithful KAHN port; deferred performance fast-follow, not a seam defect, not an SC-1 blocker.
  • cairnet_agent_id field-name vs value — benign-but-misleading naming wart; becomes more confusing once the value must carry an agent: prefix. The next round’s decision doc should call it out.
  • 4 pre-existing petrova-codes cli failurestests/registry.test.ts ×3 (registry-applicability data) + tests/validate.test.ts ×1 (MR-4 filename validator). Orthogonal to this seam; tsc clean; 309–313 passing depending on round. The MR-4 violator is the pre-existing docs/decisions/2026-05-15-petrova_act_registry_edit-choco-hq.md (underscores in slug) — out of SP-1.5 scope; do not rename it here (would compound the round). Hyphen-only new doc slugs pass.

Each source line above was read at the terminal gate from primary source in the respective repo at the stated feature-branch HEADs (pebble 108b33f, airlock 59da1bee, petrova-codes feat/cairnet-seam-sp1 HEAD incl. 60375f2/3718ebb, eva-hq e2a94bf). The full petrova-codes cli suite was re-run at the gate: 4 failed / 313 passed, tsc --noEmit clean — the 4 failures are the pre-existing orthogonal set above.

SP-1.6 resolution (2026-05-18) — both tracked residuals closed

Section titled “SP-1.6 resolution (2026-05-18) — both tracked residuals closed”

Issue #126’s two next-round residuals are remediated by SP-1.6 (spec docs/specs/2026-05-18-sp1.6-stones-get-strict-params-design.md, plan docs/plans/2026-05-18-sp1.6-stones-get-strict-params-implementation.md, caller-compat audit docs/findings/2026-05-18-sp1.6-stones-get-caller-audit.md). Each task ran implement → spec-review → code-quality-review.

  • Residual 1 (the systemic fail-open, not just the sc-2 instance). Investigation reframed it: Pebble’s GET /api/cairn/stones silently ignored unknown query params → unfiltered listing → any mistyped/legacy verification could falsely pass. T2 (pebble feat/sp1.6-stones-get-strict-params 4657a535 + review-hardening 823f100) makes the endpoint reject unknown params with 400 (fail-closed), naming offenders + the allowed set derived from the single source-of-truth constant; a pure-unit allowlist/​message-rot drift guard runs DB-free (not CI-only). T1 (caller-compat audit 679958303 + accuracy correction 363d884f) proved no first-party caller sends a non-allowlisted param before the 400 was enabled (gate cleared). T3 (eva-hq feat/sp1.6-sc2-curl-fix bd34af8) corrects the human sc-2 verification curl to ?agent=agent%3Apetrova-cairnet-bridge with no since (Pebble has none; probe windows client-side) — the lying example can no longer pass vacuously, and post-T2 the old form 400s loudly. SP-1.5 R5/CR2 owner/producer split preserved.
  • Residual 2. T4 (airlock feat/sp1.6-seed-comment-fix 078333f63) makes the seed file header doc-comment enumerate cairn:read, matching the executable buildBridgeClientSpec(). Comment-only.

State: all four tasks GREEN through both review stages; work is on the four feat/sp1.6-* feature branches, NOT yet merged (no repo main affected). Issue #126 stays open until these branches are integrated — closed referencing the merge, per the same don’t-claim-done-before-integration discipline applied to SP-1.5.