Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .claude/skills/chain-of-custody/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,23 @@ unless a chg review names the LANDED revision (the `sha` of the newest applied `
of the current binding). Escalation in general does NOT block verify; only this edge does.
Neither refusal records a custody violation.

**A chg story's CI evidence (US-47.6) — `Loopctl.Changes.TrunkEvidence`.** Right after that gate,
on the same five paths, the landing's TRUNK commit (`data.merge_sha`, never `event.sha`) must be in
`verified/master` (`PullRequestSource.contains?/3`; `chg_landing_not_trunk_verified`) and no applied
`reverted` may follow the landing (`chg_landing_reverted`). The forge is read BEFORE the
transaction (`assess/3`, `assess_many/3`) and applied under the lock only to the landing it was
made for (`ensure_trunk_verified/4`); any forge failure, a 404 included, is
`{:chg_trunk_unevaluated, reason, retry_after}` — a 503 with `Retry-After`, never a pass. Do not
move the read into the Multi (a lock held across a network call), and do not short-circuit on the
local `trunk_verified_sha`: the ref is the evidence.

**Custody-orphan predicates are the ONLY guard for the never-dispatched case.** The DB CHECK
`stories_reported_done_requires_agent` is satisfied whenever `implementer_dispatch_id IS NULL`, which
is exactly the orphaned shape — do not delete `custody_orphaned?/1` or `custody_unattributed?/1` as
redundant with the constraint.

**Backfill is the fourth way to reach `verified`, and its guard is `stories.lifecycle_entered_at`.**
`backfill_story/4` / `ensure_mark_complete_allowed/2` certify with no report, no review record and no
`backfill_story/4` / `ensure_mark_complete_allowed/3` certify with no report, no review record and no
verifier, so they are confined to work that never entered the lifecycle. State alone cannot answer
that: `force_unclaim_story/3` CLEARS `assigned_agent_id`, and a legacy bearer claim never writes
`implementer_dispatch_id`, so claim -> force-unclaim leaves a worked story indistinguishable from
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ All notable changes to loopctl are documented here.
binding are answered exactly as before. Migration `20261019170000_add_chg_review_custody_to_review_records`
adds the two nullable columns, a CHECK that they are set together, and the unique index;
additive, no deploy ordering. MCP: loopctl-mcp-server 3.3.0.
- **A chg story's CI evidence is `verified/master` (US-47.6).** On a story bound to a chg
change, every path that reaches `verified` (`POST /stories/:id/verify`,
`/epics/:id/verify-all`, `/stories/bulk/verify`, `/stories/:id/backfill`,
`/stories/bulk/mark-complete`) now also asks GitHub whether the trunk commit of the newest
applied `landed` event (`data.merge_sha`) is an ancestor of, or equal to, the repository's
`refs/heads/verified/master` (`GET /repos/:repo/compare/<merge_sha>...verified/master`),
read before the verify transaction with the same bounded timeouts as the other forge reads.
409 `chg_landing_not_trunk_verified` when it is not (or the landing named no valid trunk
commit); 409 `chg_landing_reverted` when an applied `reverted` event follows the landing;
503 `chg_trunk_unevaluated` with `Retry-After` and an `error.reason` when nothing could be
decided — a timeout, an unreachable forge, a 5xx or rate limit (the forge's own delay), a
401/403/422, a missing `verified/master` ref or commit (404, `trunk_ref_not_found`), no
credential for the repository (`credential_unavailable`, Retry-After 3600), or a new landing
in between (`landing_changed`). verify-all and the bulk paths report each per story. Stories
with no change binding are answered exactly as before and never reach the forge.
**Operator action:** the tenant's forge token (or the operator `GITHUB_TOKEN` lent through
`VERIFICATION_OPERATOR_TOKEN_TENANTS`) must be able to read the chg repository's commits —
a classic token's `repo` scope for a private repository, or a fine-grained token's
`Contents: read`; without it every chg verify answers 503 `forge_forbidden`/`trunk_ref_not_found`.
No migration. MCP: loopctl-mcp-server 3.4.0.
- **Intake-source mode `chg` (US-47.2).** `POST /api/v1/intake/sources` and `PATCH
/api/v1/intake/sources/:id` accept `mode: "chg"` beside `pr`, plus two fields a `chg`
source must carry: `change_events_secret` (the HMAC key chg-server will sign its event
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ revoked or not — an expired chg key's live lineage is `[]`, which reject would
rooted at a `chg_root_dispatch_id` ANY intake source of the tenant names, live or revoked,
either mode, is refused `chg_root_cannot_verify` by verify, reject, verify-all, bulk
verify/reject, backfill and bulk mark-complete (`refuse_chg_root_verifier/2`,
`progress.ex:2611`; `BulkOperations.refuse_chg_root/3`, `bulk_operations.ex:389`) and
`progress.ex:2619`; `BulkOperations.refuse_chg_root/3`, `bulk_operations.ex:394`) and
`chg_root_review_only` by claim, bulk claim and report (`refuse_chg_root_actor/3`,
`progress.ex:272`). Both halves read the lineage through ONE function,
`ReviewCustody.caller_lineage/2` (minting lineage, else the call's live one), and the
Expand All @@ -262,10 +262,10 @@ landed event's own transaction over the control-only edge `:review_missing`
fails CLOSED: a refused escalation rolls the whole landing back (`:busy`, or
`change_event_failed`, logged and counted) so chg redelivers it — the merge never commits
without its hold. Such a story is never verified: every path that reaches `verified` —
verify and verify-all through `chg_landed_review/2` (`progress.ex:2698`), bulk verify
through `process_verify/3` (`bulk_operations.ex:421`), backfill and bulk mark-complete
through `guard_backfill/2` (`progress.ex:2792`) — calls one function,
`ReviewCustody.ensure_landed_review/3` (`lib/loopctl/changes/review_custody.ex:215`). It refuses a chg story with
verify and verify-all through `chg_landed_review/2` (`progress.ex:2736`), bulk verify
through `process_verify/3` (`bulk_operations.ex:433`), backfill and bulk mark-complete
through `guard_backfill/3` (`progress.ex:2847`) — calls one function,
`ReviewCustody.ensure_landed_review/3` (`lib/loopctl/changes/review_custody.ex:208`). It refuses a chg story with
`review_missing_escalated` while its stage is escalated over `:review_missing` (a human
resolves it) and with `chg_review_not_for_landed_revision` unless a chg review names its
LANDED revision: the `sha` of the newest APPLIED `landed` event of its current binding. A
Expand Down
8 changes: 5 additions & 3 deletions docs/user_stories/epic_47_change_protocol/us_47.6.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@
"acceptance_criteria": [
{
"id": "AC-47.6.1",
"description": "For a :chg source, the CI-evidence input passes only when the current binding's landed sha is an ancestor of verified/master (compare by sha) and no reverted event follows it. That a review_records row names the landed revision is US-47.5's verify gate (AC-47.5.5, 409 chg_review_not_for_landed_revision), which already runs on every verify path; it is not re-implemented here."
"description": "For a :chg source, the CI-evidence input passes only when the current binding's landed sha \u2014 the TRUNK commit of its newest applied `landed` event, `data.merge_sha`, never the envelope `sha` (the revision commit, which is US-47.5's to certify) \u2014 is an ancestor of, or equal to, verified/master (compare by sha) and no reverted event follows it. That a review_records row names the landed revision is US-47.5's verify gate (AC-47.5.5, 409 chg_review_not_for_landed_revision), which already runs on every verify path; it is not re-implemented here."
},
{
"id": "AC-47.6.2",
"description": "A compare call that times out or errors answers unevaluated with Retry-After; it is never read as a pass."
"description": "A compare call that times out or errors answers unevaluated with Retry-After; it is never read as a pass. A 404 (no verified/master ref yet, or the trunk commit not on the forge yet) is unevaluated too, never a refusal: chg has not verified it yet, which is not a failure of the story."
},
{
"id": "AC-47.6.3",
Expand Down Expand Up @@ -81,7 +81,9 @@
}
],
"technical_notes": [
"Every forge call authenticates with the tenant's own GitHub token (#936)."
"Every forge call authenticates with the tenant's own GitHub token (#936).",
"The ancestor check reuses `PullRequestSource.contains?/3` (`GET /repos/:repo/compare/<merge_sha>...verified/master`): the status describes the HEAD relative to the BASE, so `ahead` or `identical` means merge_sha is an ancestor of verified/master, and `behind` or `diverged` means it is not.",
"The forge read runs BEFORE the verify transaction; the answer is applied under the story lock only to the landing it was made for (a newer landing in between is unevaluated `landing_changed`). A local `trunk_verified_sha == merge_sha` is not taken as the answer: the ref is."
],
"dependencies": [
"US-47.4",
Expand Down
36 changes: 36 additions & 0 deletions lib/loopctl/api_spec/messages.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,40 @@ defmodule Loopctl.ApiSpec.Messages do
"change has not landed, or no chg review names that revision (a review of an earlier " <>
"revision, or one recorded off the chg path, does not count)."
end

@doc """
The 409 `chg_landing_not_trunk_verified` message (US-47.6; verify, verify-all, bulk verify,
backfill, bulk mark-complete on a chg story).
"""
@spec chg_landing_not_trunk_verified() :: String.t()
def chg_landing_not_trunk_verified do
"This story is bound to a chg change, so its CI evidence is chg's trunk verifier: the " <>
"trunk commit its newest applied `landed` event names (data.merge_sha) must be an " <>
"ancestor of, or equal to, the repository's verified/master. The forge says it is not " <>
"(chg has not verified that commit yet), or the landing named no valid trunk commit. " <>
"Retry once chg's `verified` event for a commit containing the landing has arrived."
end

@doc """
The 409 `chg_landing_reverted` message (US-47.6; every verify path, on a chg story).
"""
@spec chg_landing_reverted() :: String.t()
def chg_landing_reverted do
"This story is bound to a chg change whose newest landing was reverted: an applied " <>
"`reverted` event follows it. A reverted landing is never verified; the story is " <>
"verifiable again only after the change lands anew."
end

@doc """
The 503 `chg_trunk_unevaluated` message (US-47.6; every verify path, on a chg story).
"""
@spec chg_trunk_unevaluated() :: String.t()
def chg_trunk_unevaluated do
"This story is bound to a chg change, and whether its landing is in verified/master " <>
"could not be established: the forge timed out, failed, was rate-limited, refused the " <>
"credential, has no verified/master ref (or no such commit) yet, no credential reads " <>
"the repository, or a new landing arrived while this call ran. Nothing was decided and " <>
"nothing changed. `reason` names which; retry after `retry_after_seconds` (the " <>
"Retry-After header)."
end
end
2 changes: 1 addition & 1 deletion lib/loopctl/api_spec/schemas.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3660,7 +3660,7 @@ defmodule Loopctl.ApiSpec.Schemas do
"repository, and `fail` adds `failed_check` and `conclusion`. With no verdict " <>
"(`error`), `ci_unavailable_reason` is a short code with no URL and no " <>
"repository name (e.g. `credential_unavailable`, `no_intake_source`, " <>
"`no_required_checks`, `no_story_branch`, `empty_change`, " <>
"`no_required_checks`, `no_story_branch`, `chg_landed_on_trunk`, `empty_change`, " <>
"`ci_definition_changed`, `ci_wait_exhausted`, `forge_unavailable`, " <>
"`database_busy`, " <>
"and for an abbreviated `commit_sha` `unresolved_sha` or " <>
Expand Down
34 changes: 31 additions & 3 deletions lib/loopctl/bulk_operations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Loopctl.BulkOperations do
alias Loopctl.Artifacts.VerificationResult
alias Loopctl.Audit
alias Loopctl.Changes.ReviewCustody
alias Loopctl.Changes.TrunkEvidence
alias Loopctl.Delivery.Stages
alias Loopctl.Dispatches
alias Loopctl.Progress
Expand Down Expand Up @@ -129,7 +130,10 @@ defmodule Loopctl.BulkOperations do
orch_id: orchestrator_agent_id,
actor_id: actor_id,
actor_label: actor_label,
caller_lineage: caller_lineage(tenant_id, opts)
caller_lineage: caller_lineage(tenant_id, opts),
# US-47.6: the chg stories' forge reads, made BEFORE the locking transaction, and
# only for a story the landed-review gate would let through.
trunk: TrunkEvidence.assess_many(tenant_id, sorted_ids, &landed_review_ok?(tenant_id, &1))
}

AdminRepo.transaction(fn ->
Expand Down Expand Up @@ -251,7 +255,10 @@ defmodule Loopctl.BulkOperations do
tenant_id: tenant_id,
orch_id: orchestrator_agent_id,
actor_id: actor_id,
actor_label: actor_label
actor_label: actor_label,
# US-47.6: the chg stories' forge reads, made BEFORE the locking transaction, and
# only for a story the landed-review gate would let through.
trunk: TrunkEvidence.assess_many(tenant_id, sorted_ids, &landed_review_ok?(tenant_id, &1))
}

AdminRepo.transaction(fn ->
Expand Down Expand Up @@ -291,7 +298,7 @@ defmodule Loopctl.BulkOperations do
actor_label: actor_label
} = ctx

with :ok <- Progress.ensure_mark_complete_allowed(story, ctx.lifecycle_ids),
with :ok <- Progress.ensure_mark_complete_allowed(story, ctx.lifecycle_ids, ctx.trunk),
{:ok, updated} <- apply_mark_complete(story) do
create_mark_complete_result(tenant_id, story, orchestrator_agent_id, params)

Expand Down Expand Up @@ -418,6 +425,11 @@ defmodule Loopctl.BulkOperations do
end
end

# A story the landed-review gate refuses is refused again under the lock, so asking the
# forge about it would spend a credentialed call on an answer nobody reads.
defp landed_review_ok?(tenant_id, story_id),
do: ReviewCustody.ensure_landed_review(tenant_id, story_id) == :ok

defp process_verify(story, params, ctx) do
%{
tenant_id: tenant_id,
Expand All @@ -432,6 +444,10 @@ defmodule Loopctl.BulkOperations do
# US-47.5 (AC-47.5.5): the SAME gate `Progress.verify_story/4` runs, on the locked
# story — a chg story is verified only on a review of its landed revision.
:ok <- ReviewCustody.ensure_landed_review(tenant_id, story.id),
# US-47.6 (AC-47.6.1): the SAME trunk-evidence gate, applied to the landing the
# pre-transaction assessment was made for.
:ok <-
TrunkEvidence.ensure_trunk_verified(tenant_id, story.id, Map.get(ctx.trunk, story.id)),
{:ok, updated} <- apply_verification(story) do
create_verification_result(tenant_id, story, orchestrator_agent_id, params)
audit_verification(tenant_id, story, updated, actor_id, actor_label, orchestrator_agent_id)
Expand Down Expand Up @@ -1001,6 +1017,18 @@ defmodule Loopctl.BulkOperations do
defp format_reason(:chg_review_not_for_landed_revision),
do: "chg_review_not_for_landed_revision: " <> Messages.chg_review_not_for_landed_revision()

# US-47.6: each leads with its code, so a bulk caller can branch on it.
defp format_reason(:chg_landing_not_trunk_verified),
do: "chg_landing_not_trunk_verified: " <> Messages.chg_landing_not_trunk_verified()

defp format_reason(:chg_landing_reverted),
do: "chg_landing_reverted: " <> Messages.chg_landing_reverted()

defp format_reason({:chg_trunk_unevaluated, reason, retry_after}),
do:
"chg_trunk_unevaluated: #{reason}; retry after #{retry_after} seconds. " <>
Messages.chg_trunk_unevaluated()

defp format_reason(:story_rejected),
do: "story is rejected; investigate instead of marking it complete"

Expand Down
93 changes: 93 additions & 0 deletions lib/loopctl/changes/landings.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
defmodule Loopctl.Changes.Landings do
@moduledoc """
The two reads every chg custody gate must agree on (Epic 47): which of a story's change
bindings is its NEWEST, and which `landed` event of that binding's change is its newest
APPLIED one. `Loopctl.Changes.ReviewCustody` (US-47.5, the landed-review gate) and
`Loopctl.Changes.TrunkEvidence` (US-47.6, the trunk gate) both read them here, so the two
gates can never judge different landings of the same story.

The newest binding is the current one when it is unsuperseded; a story whose newest binding
is superseded has no current binding. Both reads are set-based, so a batch costs one query
each, and tenant-scoped on `repo` (default `AdminRepo`), the custody-read convention.
"""

import Ecto.Query

alias Loopctl.AdminRepo
alias Loopctl.Changes.Binding
alias Loopctl.Changes.Event
alias Loopctl.Intake.Source

@type landing :: %{
id: Ecto.UUID.t(),
sha: String.t() | nil,
event_seq: integer(),
data: map(),
source_repo: String.t() | nil
}

@doc "Each story's newest binding, keyed by story id; a story with none is absent."
@spec newest_bindings(Ecto.Repo.t(), Ecto.UUID.t(), [Ecto.UUID.t()]) ::
%{optional(Ecto.UUID.t()) => Binding.t()}
def newest_bindings(repo \\ AdminRepo, tenant_id, story_ids) do
repo.all(
from b in Binding,
where: b.tenant_id == ^tenant_id and b.story_id in ^story_ids,
distinct: [asc: b.story_id],
order_by: [desc: is_nil(b.superseded_at), desc: b.inserted_at, desc: b.id]
)
|> Map.new(&{&1.story_id, &1})
end

@doc "`newest_bindings/3` for one story: the binding, or nil."
@spec newest_binding(Ecto.Repo.t(), Ecto.UUID.t(), Ecto.UUID.t()) :: Binding.t() | nil
def newest_binding(repo \\ AdminRepo, tenant_id, story_id),
do: Map.get(newest_bindings(repo, tenant_id, [story_id]), story_id)

@doc """
The newest APPLIED `landed` event of each binding's change, keyed by `{repo, change_id}`,
with the `repo_full_name` of the intake source that delivered it (`source_repo`).
"""
@spec newest_applied_landings(Ecto.Repo.t(), Ecto.UUID.t(), [Binding.t()]) ::
%{optional({String.t(), String.t()}) => landing()}
def newest_applied_landings(repo \\ AdminRepo, tenant_id, bindings)

def newest_applied_landings(_repo, _tenant_id, []), do: %{}

def newest_applied_landings(repo, tenant_id, bindings) do
keys = MapSet.new(bindings, &{&1.repo, &1.change_id})

repo.all(
from e in Event,
left_join: s in Source,
on: s.id == e.source_id and s.tenant_id == e.tenant_id,
where:
e.tenant_id == ^tenant_id and e.repo in ^Enum.map(bindings, & &1.repo) and
e.change_id in ^Enum.map(bindings, & &1.change_id) and e.kind == "landed" and
not is_nil(e.applied_at),
distinct: [asc: e.repo, asc: e.change_id],
order_by: [desc: e.event_seq],
select: %{
key: {e.repo, e.change_id},
landing: %{
id: e.id,
sha: e.sha,
event_seq: e.event_seq,
data: e.data,
source_repo: s.repo_full_name
}
}
)
|> Enum.filter(&MapSet.member?(keys, &1.key))
|> Map.new(&{&1.key, &1.landing})
end

@doc "`newest_applied_landings/3` for one binding: the landing, or nil."
@spec newest_applied_landing(Ecto.Repo.t(), Ecto.UUID.t(), Binding.t()) :: landing() | nil
def newest_applied_landing(repo \\ AdminRepo, tenant_id, %Binding{} = binding),
do:
Map.get(
newest_applied_landings(repo, tenant_id, [binding]),
{binding.repo, binding.change_id}
)
end
Loading
Loading