Known Issues & Lessons Learned
On this page
- Known Issues
- Workflow tool misuse: scout agents that edit files (2026-06-04 lesson)
- Force-merge
PUT /mergereturns405whiledetailed_merge_status: preparing(2026-06-05 lesson) - Force-merge runbook (CI-bypass for known-infra failures)
- Resuming a stale single-commit branch (2026-06-06 lesson)
- Branch hygiene (CRAIG-specific cadence)
- Don’t pre-source
.ports.envbeforegit push cargo denyblocks all pushes repo-wide on a new RUSTSEC advisory- check-docs treadmill: synced content live-tracks the template
- Externally-blocked stakeholder-filed issues
- Lessons Learned
|
Project-specific gotchas live here; high-level CRAIG patterns (problem-type URL catalogue, RFC 9457 discipline, authz cousin-handler pattern, etc.) live in the Antora docs under |
Known Issues
-
Lost exec bit on
.githooks/* (lesson 2026-06-07): Git stores the executable bit in the tree (100755vs100644). Cloning on Windows withoutcore.fileModeset, an over-eagerchmod -x .githooks/, or a clean re-add afterchmod 644can land the hooks at100644. Withcore.hooksPath .githooksstill set, Git silently no-ops the hook (treats it as non-executable text) and unvalidated code reaches the remote. CI jobhooks-exec-checkin.gitlab-ci.ymlis the regression gate — runs on every MR touching.githooks/*AND every main-branch push, fails with the offending path + actual mode. Fix:chmod +x .githooks/* && git add --chmod=+x .githooks/* && git commit. -
RabbitMQ user: Must be in
definitions.jsonwith SHA-256 hash (not env vars). -
Keycloak token lifetime: 30 min. Long test sessions may hit 401s.
-
E2E seed-manifest alignment:
tests/e2e/lib/seed.tsis committed and gets regenerated oncargo xtask e2efrom the currentCRAIG_SEED+CRAIG_FAMILIESenv vars (defaults:42/12). The committed file must match those defaults or freshcargo xtask dev startseeds the DB with one shape while specs import IDs from another, and symptoms look likelocator.selectOption: Timeout 10000ms exceeded. The generator itself is byte-deterministic for identical inputs — guarded bymanifest_is_byte_deterministic_across_runsintools/craig-seed/tests/integration.rs. If the committed manifest goes out of sync, regenerate with defaults:CRAIGACTIVE_STATE_BUNDLES=georgia cargo run -p craig-seed — --seed 42 --families 12 --output-dir tests/e2e/.seed-sql --manifest tests/e2e/lib/seed.ts(the seed jurisdiction comes from the active bundle since Plan U Step 10;CRAIGACTIVE_STATE_BUNDLESis required). E2E specs should avoid index-based lookups (casesList[0]); use the exportedfindCase/findHome/etc helpers with a predicate instead. -
CI 2xlarge runner disk OOM is recurring:
ld: final link failed: No space left on deviceduringcargo nextest --workspaceis upstream-runner infra, not code. The pre-push battery (cargo fmt --check,cargo clippy --workspace --all-targets — -D warnings,cargo nextest run --workspace,cargo xtask validate) is the authoritative quality gate and covers everything CI duplicates. The user has standing authorization to force-merge after pre-push passes — see Force-merge runbook below. -
Keyed devstack: craig-cases refuses boot on key↔data mismatch (since epic &66 C7, #987): at startup cases verifies the mounted
CRAIG_FIELD_ENCRYPTION_KEYagainst the seeded data’scrypto_key_lineageanchor (ADR-048 §D3) and refuses to start on a mismatch — compose then reportsdependency craig-cases failed to start. The xtask paths self-heal (the.devstack/field-key.kcvstaleness marker forces the wipe + keyed reseed automatically), but one edge does not: if.devstack/markers are absent while an old data volume persists (fresh clone against an existing volume, or a manually cleared.devstack/),dev starttakes the cold-start path — no staleness check runs — and cases refuses boot withKeylessLineageWithKey/KcvMismatch. This is the designed defense-in-depth, not a bug; the refusal message names the fix:cargo xtask dev reseed. -
Inbox retries past strike 2 are operator-paced (epic &77 / ADR-062 §A, 2026-08-02): a failing MQ handler gets exactly TWO automatic attempts (broker 2-strike: nack-requeue, then reject → DLQ). The #1197 parking replayer serves the DLQ consumer’s own failures ONLY — nothing automatic returns a dead-lettered envelope to the source queue; attempts 3–5 come from the ADR-022 §D4 admin replay, and the counted-attempt budget (
error_count, terminal at 5) spans those cycles. Symptom in tests/ops: anevent_inboxrow stuck aterror_count=2, processed_at NULLis WAITING FOR AN OPERATOR, not retrying. Beyond-grace watchdogs + DLQ depth gauges are the signals; the operator escape hatch (delete the terminal row + replay) is in the ADR-062 runbook section. A fault-injection test whose fault does not self-clear before strike 2 can therefore never converge — see the casesdegraded_pass_is_refused_counted_and_converges_on_the_retryleg for the self-disarming-trigger pattern that keeps such tests deterministic. -
event_inbox.payload_hashlegacy rows are NULL until #1273 (epic &77 A1): pre-program inbox rows carry no identity hash, so the collision check skips the hash leg for them (backfilled at next stamp). The post-horizonNOT NULLmigration is #1273 — do not write code assuming the column is always populated before it lands. -
request_claimsreplays are bounded by a 30-day horizon (epic &77 §B, ADR-062 §H): a client retry of a convert-class POST afterCRAIGREQUEST_CLAIMSWINDOW_DAYS(default 30;0disables retention, never the guard) is a NEW operation by design — the claim row was pruned. Same boundary for §U upload/export attempts at the 31-day tombstone retention. This is the documented fresh-attempt boundary, not a bug. -
RabbitMQ ≥4.2 metadata writes can transiently time out under I/O contention (Khepri) (#1401, forensics 2026-08-11): the broker’s Raft-backed Khepri metadata store makes a transient consensus-write timeout a DESIGNED failure mode —
queue.declare/queue.bindanswer with a connection-classINTERNAL-ERROR: … metadata store operation timed out(which closes the whole connection), typically minutes after a broker recreation (the volume-less broker cold-starts the store, including the Mnesia→Khepri table migration, on every recreate) under battery-tail disk contention. Posture: the image is pinned to an exact patch (devstack/rabbitmq/Dockerfile— the store engine can never change under an infra rebuild again); the test harness’sEventCollectorretries the transient class boundedly with a fresh connection per attempt (craig-test-libevents.rs, fail-loud after 5); production subscriptions already self-heal via craig-mq’s supervised reconnect loop (its module doc: reconnect with capped backoff
redeclare), so no per-declare retry was added there. Anything OTHER than the INTERNAL-ERROR class (ACCESS-REFUSED, PRECONDITION-FAILED, …) still fails immediately. -
CI raw-compose devstack jobs need the
CRAIG_CI_AGE_KEYproject variable (ADR-064 U7, #1385; wasCRAIG_FIELD_ENCRYPTION_KEYpre-switchover): the compose fileenv_file-requiresdevstack/field-key.env; the pentest/perf jobs materialize it viadevstack/ci/write-field-key.sh, which decrypts the repo-committed store (secrets/dev.yaml) with the masked + protected CI age identityCRAIG_CI_AGE_KEY(minted viacargo xtask secrets init --for-ci; see #1031). Unset variable ⇒ the script hard-errors naming this fix (ADR-048 §D5: never a keyless CI devstack). Protected means a manual perf-stress run on an unprotected branch hard-errors by design — trigger it frommain. These jobs remainallow_failurefor the unrelated recurring runner-infra reds (disk OOM, above).
Workflow tool misuse: scout agents that edit files (2026-06-04 lesson)
When launching a Workflow with parallel subagents for scouting (categorize emissions, draft a per-crate playbook, etc.), the agents may interpret their task as "make the workspace fit the plan" and start editing files in parallel. During Plan M Tier 1d, a scout-only workflow commented out the Plan H Step 2 transitional allow blocks across multiple crates simultaneously, leaving the working tree broken across 13+ lib roots. The agents thought they were "probing what would fire" — but the edits were uncoordinated and conflicting.
Mitigation:
-
Explicitly frame scout prompts as "read-only; no file edits". Repeat the constraint at the end of the prompt where it’s likely to be re-read.
-
If parallel file mutation is genuinely needed, set
isolation: 'worktree'so each agent gets its own checkout. -
For force-merge-serialized lift work (Plan M MRs ship one at a time through the force-merge runbook), Workflow adds overhead without parallelism — execute sequentially in the main loop. The single-agent inline loop is the right shape.
-
Recovery: if a scout workflow has already edited files,
TaskStopthe workflow, thengit checkout — .to revert all unwanted edits. Save only the intentional edits done outside the workflow.
Force-merge PUT /merge returns 405 while detailed_merge_status: preparing (2026-06-05 lesson)
This is distinct from the 405 Method Not Allowed documented in the Force-merge runbook below (which fires while detailed_merge_status: ci_must_pass). When opening a new MR via the GitLab API, the MR sits in detailed_merge_status: preparing for ~10-30s while CI registers; PUT /merge_requests/N/merge during this window also returns 405. Mitigation: poll detailed_merge_status until it leaves preparing before issuing PUT, OR retry the PUT after a 30s sleep — the same call succeeds once the state advances.
Additionally observed during Plan M force-merges: the PUT can return 405 even with cancel=200 immediately preceding, because the cancel hasn’t propagated. A re-check of MR state right after a 405 sometimes shows state: merged — the actual merge landed during the call’s race window. Always verify merge outcome via a follow-up MR fetch or git ls-remote, not the PUT exit code alone.
curl -sf swallows JSON-error bodies on 4xx/5xx. When debugging merge-call failures, switch to curl -X PUT without -sf and pipe through python3 -m json.tool to see the actual error body — the message field tells you whether the failure is 405 not_mergeable, 405 preparing, or a downstream conflict.
Force-merge runbook (CI-bypass for known-infra failures)
When CI fails on the 2xlarge disk-OOM pattern AND the local pre-push battery is green, force-merge via the three-step API sequence. Plain glab mr merge rejects client-side and raw PUT …/merge returns 405 Method Not Allowed while detailed_merge_status is ci_must_pass. Clearing the running-pipeline gate AND the armed-auto-merge gate unblocks the endpoint.
PROJECT=gadhs%2Fapplication%2Fccwis%2Fcraig
IID=<merge-request-iid>
# 0. Arm auto-merge first so a pipeline ID is assigned to the MR
glab mr merge $IID --auto-merge --yes
sleep 25 # let the pipeline ID populate
# 1. Read pipeline ID from the MR
PIPELINE_ID=$(curl -sS -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT/merge_requests/$IID" \
| python3 -c "import json,sys; d=json.load(sys.stdin); p=d.get('head_pipeline') or {}; print(p.get('id',''))")
# 2. Cancel pipeline + cancel auto-merge + PUT merge (all in order)
curl -sS -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT/pipelines/$PIPELINE_ID/cancel"
curl -sS -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/$PROJECT/merge_requests/$IID/cancel_merge_when_pipeline_succeeds"
sleep 2
curl -sS -X PUT -H "PRIVATE-TOKEN: $GITLAB_TOKEN" -H "Content-Type: application/json" \
-d '{"should_remove_source_branch":true}' \
"https://gitlab.com/api/v4/projects/$PROJECT/merge_requests/$IID/merge"
Verify with glab mr view $IID — state should flip to merged and merge_commit_sha populates.
Apply IMMEDIATELY after pre-push passes — don’t wait for CI to confirm what the local battery already validated. Waiting for the recurring 2xlarge disk-OOM costs 15–30 min per MR.
When NOT to apply: pre-push fails (real test failures, clippy lints, fmt drift). Fix the underlying issue rather than force-merge — the bypass exists for upstream-runner-infra problems on already-validated work.
GITLAB_TOKEN lives in /home/bitskrieg/code/craig/.env.local (not in global config); source it explicitly when running the runbook.
Don’t rely on merge_when_pipeline_succeeds. Because CI never passes on this project (the recurring 2xlarge disk-OOM above), arming auto-merge just delays the merge until a pipeline that will fail — a ~25-minute wait for nothing. Once pre-push is green, issue the merge directly: POST /merge_requests to open the MR, then PUT /merge_requests/N/merge immediately after. If you never arm auto-merge, the wait-and-cancel ceremony above is unnecessary — the endpoint is unblocked because there’s no armed-auto-merge gate to clear. The pre-push battery is the canonical correctness gate; CI only duplicates what it already validated.
Resuming a stale single-commit branch (2026-06-06 lesson)
When resuming feature work whose branch was authored weeks ago and never pushed (e.g., a paused per-partner crate sitting on a single commit off a stale parent), the rebase path works cleanly when the branch carries exactly one commit:
-
git branch --contains <sha>confirms which branch holds the commit. -
git log <sha>^ -1shows the parent — verify it’s reachable from current main (git merge-base main <sha>should return the parent or an earlier commit). -
git rebase --onto main <parent-sha>replays the single commit on top of current main. -
Conflicts will land in Cargo.toml (workspace
members+[workspace.dependencies]— usually trivial; keep both sides) and CHANGELOG.adoc (Unreleased section conflict — place both entries chronologically, oldest below newest). -
After conflict resolution:
cargo fmt --all+cargo clippy --workspace --all-targets — -D warnings+cargo nextest run --workspace. -
If the branch carries pre-Plan-M lint discipline (transitional
#![allow(clippy::pedantic | clippy::cargo)]at lib root), thecargo xtask validateno-transitional-allows gate will reject the push. Apply the lift per Quality Gates & Enforcement (CRAIG) (§ Resuming pre-Plan-M crate authoring) as a follow-up commit on the same branch (never amend the original commit per the git-workflow standard).
For untracked working-tree work (no commit, just files sitting in crates/<name>/), branch off current main and git add the files directly — no rebase needed. Apply the same lint-discipline check before pushing. The no-transitional-allows gate will fire if the lib root retains the pre-Plan-M block.
Branch hygiene (CRAIG-specific cadence)
-
Wait for main before branching: when an MR is in flight, let auto-merge land, then
git checkout main && git pull --ff-only origin main, then branch. Branching from local main while an MR is mid-merge producesCHANGELOG.adoc/Cargo.tomlrebase conflicts that have to be untangled manually. -
Branch cleanup after each merge (not at session end). After every merge, run:
git branch --merged main | grep -v '^\*\| main$' | xargs -r git branch -d git remote prune originKeeps the local branch list and
origin/refsaligned with what’s actually on the remote.
Don’t pre-source .ports.env before git push
The pre-push hook’s cargo xtask e2e stage refreshes the devstack mid-run, reassigning random host ports and rewriting .ports.env. If you have pre-sourced .ports.env into your shell, the stale ambient CRAIG_TEST__* variables now point at dead ports; later hook stages read those stale vars over the fresh file and fail with dead-port connection errors that masquerade as a security/perf-scan failure but are really a port race.
Push in a clean environment — verify with env | grep -c CRAIG_TEST__ returning 0 and let each xtask stage load .ports.env itself. (Sourcing .ports.env is still correct for a bare cargo nextest run --run-ignored all, which does not auto-load it.) Never --no-verify; if you must skip a stage, use its documented opt-out (SKIP_PERF=1 / SKIP_SECURITY=1 / SKIP_SDK=1) only after confirming that stage passes standalone.
cargo deny blocks all pushes repo-wide on a new RUSTSEC advisory
cargo xtask validate runs cargo deny check, so the moment a new RUSTSEC advisory lands against any dependency in the tree, every push starts failing — independent of what your branch changed. This is not a defect in your work.
Fix it in a dedicated fix(deps) MR, not inline in an unrelated branch: cargo update -p <crate> to the patched within-semver release on its own feature/ branch, land it, then git rebase origin/main your in-flight branch onto it (expect a CHANGELOG.adoc top-entry conflict — keep both entries). cargo deny check standalone confirms the fix before pushing. This recurs regularly (e.g. anyhow, ammonia).
check-docs treadmill: synced content live-tracks the template
Synced surfaces (.claude/rules/, docs/modules/standards/, .githooks/*, and the rest of the sync manifest) live-track the claude-quickstart template’s main, which advances faster than this repo re-syncs. A standards page or hook can therefore bump mid-session and re-block a push that was previously clean, via cargo xtask check-docs.
Fix: run cargo xtask check-docs --fix --yes (add --allow-exec when hooks changed) to adopt the latest synced content, and land it as a separate chore: commit (the hook/CI carve-out) rather than folding it into the substantive change.
Externally-blocked stakeholder-filed issues
CRAIG receives compliance and integration issues filed by agency stakeholders that look immediately actionable but actually depend on external teams or external-system coordination (e.g. SHINES → Gateway integration owned by the OGG GGUPM team, or SSA exchange agreements for IV-E data). These are not CRAIG dev-team code tasks: the implementation spans multiple teams/systems, so building the CRAIG half of an integration that can’t be exercised end-to-end burns cycles.
How to disposition: mark such issues externally-blocked and skip them from the immediate-action backlog until the external dependency clears — treat them like other externally-owned work rather than "ready to pick up next."
Signals that an issue is externally-blocked:
-
Body references to external coordination — "OGG CR", "Gateway", "external integration", an external CR URL, or a named legacy system (SHINES, SACWIS, IES, etc.) without a clear statement of which side CRAIG owns.
-
Author + shape — filed by a named agency stakeholder (not the bot) with empty or stub acceptance criteria.
Partial actionability via the mock server: where the CRAIG-side scope is well-defined and exercisable against tools/craig-mock-server/ (which emulates the SHINES external systems in devstack), the adapter can be built and tested locally without external-team coordination — only production cutover needs the external work. Default disposition stays externally-blocked; surface the mock-actionable interpretation only when explicitly directed.
Lessons Learned
-
Nextest parallelism: Default thread count equals logical CPUs (32 on dev machine). This overwhelms Docker devstack with concurrent HTTP connections, causing random 30s reqwest timeouts. Fixed by setting
test-threads = 16(physical cores) in.config/nextest.toml. -
Docker image tags: Query registry API, don’t guess:
curl -s "https://registry.hub.docker.com/v2/repositories/{owner}/{image}/tags/…" -
Keycloak 26+ hostname v2:
KC_HOSTNAME_PORTis silently ignored;KC_HOSTNAMEmust be a full URL (e.g.,http://host.docker.internal:8180). -
curl
-sf+-w '%{http_code}':-fcauses status code concatenation. Use-swithout-f, with|| truefor fallback.