fix(unic-spec-review): reactive footer fallback when Confluence rejects an inline anchor#244
Conversation
…ts an inline anchor
When our resolver counts a unique anchor match but Confluence's stored-body
model disagrees, Confluence rejects the inline POST with HTTP 400 and the
Finding was posted nowhere — breaking the ADR-0004 "never dropped" guarantee
with a raw API error. The writer now retries rejected inline anchors as a
page-level footer comment, while auth/not-found/network errors still fail loud.
Changes:
- atlassian-fetch: add FetchError kind 'rejected' for HTTP 400 (server reachable,
refused our selection); 401/403→auth-error, 404→not-found, 5xx/network→unreachable
unchanged. Update FetchErrorKind typedef + doc comment.
- confluence-writer: extract injectable postFinding({pageId,finding,creds,fetch})
returning {id,type,reason}; retry footer only on kind:'rejected'; main() is a
thin argv/file/exit wrapper.
- review-spec: third anchoring line for reason 'inline-rejected'.
- tests: HTTP-400→rejected + full classification map in atlassian-fetch.test.mjs;
new confluence-writer.test.mjs (inline-400→footer, inline-401/network→fail loud
no footer call, footer-500→fail loud, resolver-footer→footer only).
Fixes #232
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… ci) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🔍 Comprehensive PR ReviewPR: #244 — fix(unic-spec-review): reactive footer fallback when Confluence rejects an inline anchor SummaryThe reactive footer fallback is precisely implemented: only Verdict: ✅
🟡 Medium Issues (Needs Decision)M1: Happy-path inline success not tested via
|
| # | Issue | Location | Recommended Action |
|---|---|---|---|
| L1 | finding: any — missing FindingInput typedef on exported postFinding (7 accessed properties undocumented) |
confluence-writer.mjs:37 |
Add @typedef FindingInput (2 lines, matches established pattern in atlassian-fetch.mjs) |
| L2 | created field silently dropped from CLI stdout — no changelog mention |
confluence-writer.mjs:123 + CHANGELOG.md |
Optional one-line note: "CLI stdout no longer includes created" |
| L3 | HTTP 400 from non-inline endpoints maps to kind:'rejected' — classification imprecision |
atlassian-fetch.mjs:404-406 |
Leave as-is (reclassification explicitly deferred per scope doc) |
| L4 | Footer retry loses inline-rejection context on secondary failure — logs show only the footer error | confluence-writer.mjs:55-58 |
Leave as-is (fail-loud contract verified by test; wrapping would lose err.kind) |
| L5 | PostFindingResult.id description omits empty-string sentinel (mirrors upstream PostedComment.id) |
confluence-writer.mjs:24 |
Add , or '' when absent from the response (1 word change) |
| L6 | AGENTS.md status string omits reactive fallback mention | AGENTS.md |
Leave as-is (ADR-0004 has the full update) |
| L7 | CONTEXT.md status string — same as L6 | CONTEXT.md |
Leave as-is (ADR-0004 is canonical) |
✅ What's Good
- Narrow retry catch:
err instanceof FetchError && err.kind === 'rejected'cannot accidentally absorb auth/network/not-found errors. Every non-rejected FetchError rethrows — textbook correct. - Five test scenarios: inline-400→footer-success, inline-401 fail-loud, network-throw fail-loud, double-failure, resolver-footer pass. Full required matrix, all behavior-oriented with explicit call-graph guards (
footerCalled,inlineCalled). - HTTP 400 placement in
parseJsonResponse: ordered after 401/403/404, before catch-all!res.ok. Classification hierarchy is readable. - HTTP status classification table (
atlassian-fetch.test.mjs:909-930): data-driven parametric block pins the 400→rejectedcontract as a stable, readable spec. postFindingJSDoc: ADR-0004 reference, HTTP 400 → kind mapping, "caller fails loud" summary — everything a future reader needs without looking at the implementation.- ADR-0004 already updated: dedicated 2026-06 section with full reactive fallback context.
- CHANGELOG 0.1.11: detailed, links to issue unic-spec-review: inline-anchor match-count mismatch escapes the ADR-0004 footer fallback #232, explains
'rejected'vs'unreachable'distinction.
📋 Suggested Follow-up Issues
| Title | Priority |
|---|---|
Add FindingInput typedef to postFinding in confluence-writer |
P3 |
Clarify FetchErrorKind taxonomy: 'rejected' vs generic 400 |
P3 |
Reviewed by Archon comprehensive-pr-review workflow · 5 agents · 8 unique findings
Artifacts: artifacts/runs/c76d0fb0a61ba0973b9fe72be7a65975/review/
- Add FindingInput typedef to postFinding (replaces `any`) - Update PostFindingResult.id doc: note empty-string sentinel - Add happy-path inline-success test (type:inline reason:null) - Note removal of `created` field from CLI stdout in CHANGELOG Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
⚡ Self-Fix Report (Aggressive)Status: COMPLETE Fixes Applied (4 total)
View all fixes
Tests Added
Skipped (4)All 4 skipped findings were independently recommended "leave as-is" by the reviewing agents:
Suggested Follow-up Issues(none) Validation✅ Type check | ✅ Lint | ✅ Tests (433 passed) Self-fix by Archon · aggressive mode · fixes pushed to |
…e repetition Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Why
ADR-0004 guarantees no Finding is ever dropped for lack of an anchor. That held only for mismatches our own resolver detected. A second, invisible class escaped it: when our resolver counts a unique match but Confluence's stored-body model disagrees (case, tag boundaries, entity/whitespace encoding), Confluence rejects the inline POST with HTTP 400. The old code mapped 400 →
unreachableand the writer rethrew everything — so the Finding was posted nowhere and the user saw a raw API error. This restores the guarantee end-to-end.Closes #232. Design locked via
grill-with-docs(2026-06-09); source of truth is the amended ADR-0004.What
atlassian-fetch— newFetchErrorkind'rejected'for HTTP 400 (server reachable, refused our selection).401/403→auth-error,404→not-found, network/timeout/5xx→unreachableunchanged.429/5xxclassification stays deferred. Typedef + doc comment updated.confluence-writer— orchestration extracted into an injectablepostFinding({pageId, finding, creds, fetch})→{id, type, reason};main()is now a thin argv/file/exit wrapper. The writer retries a footer comment only onkind:'rejected'from the inline POST; auth / not-found / network all fail loud and are not retried. A successful retry emitstype:'footer', reason:'inline-rejected'.review-spec— third anchoring line:Anchoring: footer fallback (inline rejected by Confluence).Tests (
node:test, injectedfetch, no live Confluence)atlassian-fetch.test.mjs: 400→rejected+ a map-drivenHTTP status classificationblock asserting 400/401/403/404/500.confluence-writer.test.mjs(new): inline-400→footer (type:'footer'/reason:'inline-rejected'); inline-401→no footer call, fails loud; inline network-throw→no footer call, fails loud; inline-rejected + footer-500→fails loud, no phantom success; resolver no-anchor→footer endpoint only, reason unchanged.432 tests pass,
typecheckclean,verify:changelogok → 0.1.11.Deviation from the investigation
The network-throw test asserts
kind:'unreachable'(not a rawTypeError):postJsonwraps thrown fetch errors asFetchError(kind:'unreachable')before they reachpostFinding. The corrected assertion still proves network errors fail loud and are never retried as footers.🤖 Generated with Claude Code