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 (pebbled6b244e) added the missing cross-repo POST→GET seam test and empirically proved the recorded value isagent:petrova-cairnet-bridge(real auth + cairn POST + Postgres; bare form matches zero stones); CR2 (eva-hq7fe44dc) set the prompt producer-principal value to the prefixed form (owner/producer split preserved); CR3 (petrova-codes6110836) realigned fixtures + added a contract-pin test locking the prefixed literal and its%3AURL encoding. The terminal whole-seam gate round 2 returned GREEN: SC-1 delivered end-to-end, C1/C2/C3/I1/I2/I3 each closed, theagent:-prefix join and the%3Around-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):
- eva-hq
prompts/petrova-wire-rocky/body.mdlines ~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:91correctly uses?agent=). Doc-correctness fix: align the sc-2 example to?agent=and drop the&since=(probe windows client-side).- airlock
scripts/seed-petrova-cairnet-bridge.tsheader doc-comment lists onlycairn:emit+knowledge:register; the executablebuildBridgeClientSpec()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”Disposition
Section titled “Disposition”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):
- airlock R2 —
scripts/seed-petrova-cairnet-bridge.ts:55:clientId: "petrova-cairnet-bridge", scopes["cairn:emit","cairn:read","knowledge:register"]. Bare client_id. - Pebble R1 —
src/pebble/api/middleware/auth.pyBearer block:sub = body.get("sub") or body.get("client_id")→ for aclient_credentialstoken,sub = "petrova-cairnet-bridge";metadata["agent_id"] = "petrova-cairnet-bridge". Correct. - Pebble — the break —
src/pebble/api/routes/cairn.py:148-154_principal_triple():binding = user.metadata.get("agent_id")→return (f"agent:{binding}", ...). The POST/stoneshandler (cairn.py:321,339) persists the stone withagent_id = "agent:petrova-cairnet-bridge"(the request-body envelopeagent_idis correctly ignored —cairn.py:312— so the petrova-codes envelope value is moot; identity is principal-derived and gets theagent:prefix). - Pebble stones-GET filter —
src/pebble/infrastructure/services/cairn_service.py:174-175:if agent: stmt = stmt.where(CairnStoneModel.agent_id == agent)— exact equality, no prefix normalization. - petrova-codes R3 probe —
cli/src/probes/rocky.ts:89:?agent=${ev.cairnet_agent_id}whereev.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 = 0 → failing 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.
Per-defect status (assembled)
Section titled “Per-defect status (assembled)”- C1 / C3 (Pebble Bearer auth): CLOSED. RFC7662, basic-auth,
fail-closed, issuer-checked, JWT-shape-gated; session/apikey paths
untouched;
client_credentialsresolvesagent_idfromclient_id. - C1↔R2 join: CLOSED. R1 parses
scope→permissions+scopes; R2 suppliescairn:emit+cairn:read; bothrequire_permissiongates pass; R1 does not depend on airlock-custom metadata ({}is fine —agent_idderives fromsub/client_id). - C2 (intrinsic emit, R4): CLOSED as written — unconditional rocky
emit after all refusal gates, deterministic
emitted_at→first_emitted_at,ANCHOR_REWRITE_INVARIANT(no silent drop), queued path still anchors, posts Bearer to the auth-gated/api/cairnroute (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/createdAtare the real_stone_to_dictkeys —cairn_service.py:71-77; no?since=; client-sidecreatedAtwindow) 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>>ineva-hq/prompts/petrova-wire-rocky/body.md(rocky evidencecairnet_agent_id, stones-GET examples, sc-2 curl) toagent:petrova-cairnet-bridge. petrova-codes R3 passesev.cairnet_agent_idthrough 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_tripleso recordedagent_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_opaquesynchttpx.postblocks the async event loop — fail-closed, faithful KAHN port; deferred performance fast-follow, not a seam defect, not an SC-1 blocker. cairnet_agent_idfield-name vs value — benign-but-misleading naming wart; becomes more confusing once the value must carry anagent:prefix. The next round’s decision doc should call it out.- 4 pre-existing petrova-codes cli failures —
tests/registry.test.ts×3 (registry-applicability data) +tests/validate.test.ts×1 (MR-4 filename validator). Orthogonal to this seam;tscclean; 309–313 passing depending on round. The MR-4 violator is the pre-existingdocs/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.
Verification status of these findings
Section titled “Verification status of these findings”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/stonessilently ignored unknown query params → unfiltered listing → any mistyped/legacy verification could falsely pass. T2 (pebblefeat/sp1.6-stones-get-strict-params4657a535+ review-hardening823f100) makes the endpoint reject unknown params with400(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 audit679958303+ accuracy correction363d884f) proved no first-party caller sends a non-allowlisted param before the 400 was enabled (gate cleared). T3 (eva-hqfeat/sp1.6-sc2-curl-fixbd34af8) corrects the human sc-2 verification curl to?agent=agent%3Apetrova-cairnet-bridgewith nosince(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-fix078333f63) makes the seed file header doc-comment enumeratecairn:read, matching the executablebuildBridgeClientSpec(). 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.