Skip to content

fix(custody): close bulk_reject self-verify gap + harden verify/review guards (parity with #252)#253

Merged
mkreyman merged 5 commits into
masterfrom
fix/ie-01b-bulk-custody-parity
Jul 1, 2026
Merged

fix(custody): close bulk_reject self-verify gap + harden verify/review guards (parity with #252)#253
mkreyman merged 5 commits into
masterfrom
fix/ie-01b-bulk-custody-parity

Conversation

@mkreyman

@mkreyman mkreyman commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Fix-forward for the lateral chain-of-custody gaps an enhanced review found in PR #252 (bulk verify / mark-complete custody guards). All findings are addressed in this one branch — no deferrals.

Fixes

1. [Medium — footgun] Rename ?-suffixed guards that don't return booleans.
verify_allowed?/2, review_conducted?/3, mark_complete_allowed?/1 returned :ok | {:error, atom}, so the ? names violated the predicate convention (an if guard?(...) would treat the error tuple as truthy and silently disable the check). Renamed to ensure_verify_allowed/2, ensure_review_conducted/3, ensure_mark_complete_allowed/1; @doc/@spec/all call sites updated. Contract (:ok | {:error, _}) unchanged.

2. [Low — doc] Document nil-identity blocking.
ensure_verify_allowed/2 @doc now states a nil orchestrator_agent_id is treated as an untrusted identity and is always blocked (maps to {:error, :self_verify_blocked}).

3. [High — security] bulk_reject was missing the self-verify guard.
Single-story Progress.reject_story runs validate_not_self_verify, but BulkOperations.process_reject did not — so an orchestrator could reject (self-adjudicate) their own implemented work, and rejection is destructive (auto_reset_agent_status wipes assigned_agent_id/reported_done_at). Added Progress.ensure_verify_allowed(story, orchestrator_agent_id) to process_reject's with chain, mirroring process_verify. A self-reject now returns an error result with reason =~ "chain-of-custody" and the story stays :unverified.

4. [Low — security] bulk_verify allowed re-verifying an already-verified story.
validate_verify_preconditions only checked agent_status == :reported_done; the single path (Progress.validate_verifiable) also blocks verified_status == :verified. Added an ordered cond so already-verified is rejected first with :already_verified.

5. [Medium — security] Review-record completed_at was unvalidated.
ReviewRecord.create_changeset cast client-supplied completed_at with no bound, so a future-dated review permanently satisfied ensure_review_conducted (which only checks completed_at > reported_done_at). Added a validate_change rejecting timestamps more than 5 minutes in the future.

6. [Low — docs] Stale MCP bulk_mark_complete description.
Updated the tool description (mcp-server/index.js) and the README summary row to state it is backfill-only for pre-existing, never-dispatched stories; dispatched stories must use the normal verify flow. Doc-string only.

Review-hardening note (this branch's follow-up commits)

An interim commit added an extra Progress.record_review guard rejecting completed_at < reported_done_at with a new :review_completed_before_reported_done atom. The review-record controller's case has no clause for that atom, so a backdated review would have crashed the endpoint (500). It was also out of scope — stale reviews are already blocked at verify time by ensure_review_conducted/3. Reverted to keep the endpoint safe and the PR scoped to the six findings.

Tests

  • test/loopctl/artifacts/review_record_test.exs (new) — FIX 5: far-future and just-past-5-min completed_at invalid; now / near-now valid.
  • bulk_operations_controller_test.exs — FIX 3: POST /stories/bulk/reject self-adjudication blocked (422, reason =~ "chain-of-custody", story stays :unverified). FIX 4: real verify-then-re-verify flow asserting verified_at unchanged and exactly one VerificationResult after the blocked re-verify.

Verification

mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict, mix dialyzer (0 errors), and full mix test (3100 tests, 0 failures, 40 excluded) all green. Committed through the full pre-commit gate (no --no-verify).

Refs private advisory GHSA-h49f-rhf8-8p6f.

mkreyman added 4 commits July 1, 2026 16:30
The three public custody guard functions (verify_allowed?, review_conducted?,
mark_complete_allowed?) return :ok | {:error, _}, not booleans. Using ? naming
violates Elixir convention and creates a dangerous footgun: misuse as a
predicate (if Progress.verify_allowed?(...), do: ...) silently disables the
guard since the error tuple is truthy.

Rename to non-predicate names:
- verify_allowed? → ensure_verify_allowed
- review_conducted? → ensure_review_conducted
- mark_complete_allowed? → ensure_mark_complete_allowed

This signals to callers that these are validation functions returning :ok | {:error, _}
and must be used with 'with' or pattern matching, not as predicates.

Also:
- Add documentation that nil orchestrator_agent_id is treated as untrusted
- Update bulk_mark_complete MCP tool description to clarify backfill-only restriction

Fixes the three Medium/Low findings from enhanced review of PR #252.
…fy/mark-complete and bulk reject

Address all five findings from PR-1 enhanced review:

1. Guard renames: verify_allowed? → ensure_verify_allowed, etc.
   (Already completed in prior work)

2. Add self-verify guard to bulk_reject: enforce chain-of-custody parity
   by blocking self-adjudication in bulk reject path (same guard as
   verify_story/4 single-path)

3. Validate completed_at in review records: add two checks:
   - completed_at must not be >5 minutes in the future
   - completed_at must be after story.reported_done_at
   Both validations happen in ReviewRecord.create_changeset + Progress.record_review

4. Block re-verifying already-verified stories in bulk: check
   verified_status==:verified in validate_verify_preconditions and reject
   with :already_verified error (already formatted by format_reason/1)

5. Update @doc for ensure_verify_allowed: document nil identity behavior
   as 'always blocked' per chain-of-custody model

Add 2 new tests for findings 2 and 4:
- test 'bulk reject blocks self-adjudication'
- test 'bulk verify blocks re-verification of already-verified story'

All 3096 tests pass; no findings deferred per NO-DEFERRALS rule.
Follow-up refinements to the bulk custody-parity branch (on top of the
rename + guard commits):

- Revert an unrequested Progress.record_review guard that rejected
  completed_at < reported_done_at with {:error,
  :review_completed_before_reported_done}. The review-record controller's
  case had no clause for that atom, so a backdated review crashed the
  endpoint (500). Stale reviews are already blocked at verify time by
  ensure_review_conducted/3, so record_review needs no extra guard.
- FIX 5: add ReviewRecord.create_changeset unit tests
  (test/loopctl/artifacts/review_record_test.exs) covering far-future
  (invalid), just-past-tolerance (invalid), near-now and now (valid);
  correct the @doc to describe only the 5-minute future check.
- FIX 4: strengthen the already-verified bulk-verify test into a real
  verify-then-re-verify flow, asserting verified_at is unchanged and
  exactly one VerificationResult exists after the blocked re-verify.
- FIX 6: update the stale mcp-server README bulk_mark_complete row to
  backfill-only (never-dispatched) semantics.

Refs private advisory GHSA-h49f-rhf8-8p6f.
Companion to the review-record test commit — carries the code and doc
changes that belong with it:

- progress.ex: revert the unrequested record_review completed_at <
  reported_done_at guard. It returned {:error,
  :review_completed_before_reported_done}, an atom the review-record
  controller's case does not match, so a backdated review crashed the
  endpoint (500). Stale reviews are already blocked at verify time by
  ensure_review_conducted/3.
- review_record.ex: correct the create_changeset @doc to describe only
  the 5-minute future check (the changeset never saw reported_done_at).
- bulk_operations_controller_test.exs: strengthen the already-verified
  bulk-verify test into a real verify-then-re-verify flow, asserting
  verified_at is unchanged and exactly one VerificationResult exists.
- mcp-server/README.md: update the stale bulk_mark_complete row to
  backfill-only (never-dispatched) semantics.

Refs private advisory GHSA-h49f-rhf8-8p6f.
@mkreyman mkreyman changed the title fix: address enhanced-review findings from PR #252 fix(custody): close bulk_reject self-verify gap + harden verify/review guards (parity with #252) Jul 1, 2026
Enhanced-review (Sonnet-5/xhigh) flagged that the 5-minute future tolerance on
review-record completed_at is generous for clock skew and leaves a bounded
window where a forward-dated review can cover a later, unreviewed re-report.
Reduce the allowance to a 60s clock-skew constant and document that the full
structural closure (binding a review record to the report generation it covers)
is tracked with the chain-of-custody invariant work.

Refs private advisory GHSA-h49f-rhf8-8p6f.
@mkreyman mkreyman merged commit 0ec2cae into master Jul 1, 2026
9 checks passed
@mkreyman mkreyman deleted the fix/ie-01b-bulk-custody-parity branch July 1, 2026 23:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant