Repository navigation
π fix(automerge): bind unmarked owner approvals to the head's observed push time - #14758
Conversation
β¦push time workflowFenceApproval judged a typed /hive decision approve (or an unmarked dashboard /approve) as bound to the current head when it was created after headPushedAt. Inside the min_head_age window headPushedAt prefers the head commit's committer date, which the pusher sets, so a later push with a backdated commit inherited an approval given on an earlier head and released the workflow-path fence for it. Approval binding now uses headObservedAt: the PR updated_at pinned on the head's first observation, which is server-side and never predates the push. headPushedAt is unchanged for the soak gate it was built for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: sec-check <280983584+hivecommons-hive[bot]@users.noreply.github.com>
Open PR overlap checkBase branch: Conflicts are checked with every PR merged onto the current tip of
|
There was a problem hiding this comment.
Decision needed: @clubanderson should confirm that unmarked owner approvals in the contributor lane may now bind to the sweep's first-observed head time (fail-closed: an approval posted between a push and the hive's first sweep of that head, followed by any other updated_at bump, will need to be re-typed) β this changes merge-trust policy for the workflow-path fence.
The code is correct as far as I can verify; routing only because it changes how an owner approval is bound to a head on the workflow-path fence, which is merge-trust policy.
What I checked in the v5 tree:
- The mechanism described in the body is real.
headPushedAtWith(src/pkg/github/automerge/automerge_head_age.go:120-154) pinsupdated_aton first observation, but whilemin_head_ageis open it consultsHeadCommitPushedAtand returnscommittedwhencommitted.Before(pinned). That value is the committer date, so a backdated commit on a later push could make a pre-push/hive decision approvecount via!created.Before(headPushedAt)atautomerge_contributor_workflow_approval.go:142. - The new
headObservedAtreturns onlytracker.observe(...)(the pinnedupdated_at) and never the commit time, so an approval typed before the push can no longer bind. Zero when nothing is pinned, which binds nothing. - Comparison is
!created.Before(...), so an approval that is itself the event that bumpedupdated_atbefore the first sweep still binds (equal timestamps pass). headPushedAtis untouched and still serves themin_head_agegate; only the approval binding changed.TestContributorLaneWorkflowFenceApprovalIgnoresCommitterDatesets committer date 10:00, observed 12:40, approval 12:00 withMinHeadAge: time.Hour, so it exercises the committer-date branch and fails without the fix.
One behaviour change worth stating plainly (info, not a defect): if an owner approves after a push but before the hive first observes the head, and something else (label flip, hive comment) bumps updated_at before that first sweep, the pinned time is later than the approval and the fence stays armed until the owner re-approves. The changelog fragment says as much; I just want the owner to have chosen it.
β hive: agent=reviewer-2 backend=copilot model=claude-fable-5.1 copilot=1.0.88
There was a problem hiding this comment.
Decision needed: @clubanderson should decide whether the workflow-path fence may silently discard a legitimate owner approval typed between a push and the hive's first observation of that head (forcing a re-approve), as the price of no longer trusting pusher-written committer dates.
The fix is correct and fail-closed; it also narrows what counts as a valid approval more than the body says, and that is a merge-policy choice for an owner.
What I verified in the tree at the PR head:
automerge_head_age.go:46-62βobserve()pins the firstupdated_atthe sweep sees for a head and returns it forever after. Server-side, so a pusher cannot move it backwards. βautomerge_contributor_workflow_approval.go:144β the binding test is!created.Before(headPushedAt), i.e. approval β₯ pinned time counts. WithheadObservedAtfeeding it, a backdated committer date can no longer let an old approval through β the two new negative cases genuinely fail on the oldheadPushedAtpath becauseheadPushedAtWith(:164-173) prefers the committer date whilemin_head_ageis open.- APPROVED-review path (
latestTrustedHumanApproval, bycommit_id) and marker-bearing/approveare untouched, as stated.
The gap I want an owner to accept explicitly (low/medium, not a bug in the diff):
The pinned updated_at is "never earlier than the push", but it can be later than a legitimate approval. updated_at is bumped by every comment and label write, and headPushedAtWith's own doc (automerge_head_age.go:146-147) names "a head first seen right after a hive comment/label write" as the normal case. Sequence: owner pushes 12:00 β owner types /hive decision approve 12:03 β hive posts a stage comment / flips a label 12:04 β automerge sweep first observes the head 12:05 with updated_at = 12:04. The 12:03 approval is now Before the pin and is dropped; the fence stays armed with no signal to the approver that their approval was discarded. The old code tolerated this via the committer date; this PR removes that tolerance on purpose, and the body only states the "never earlier" half.
That trade is probably right for a security fence β re-approving is cheap, inheriting an approval is not β but it changes when a maintainer's typed approval counts, so it should be a person's call rather than mine. If accepted, a one-line note in the changelog fragment that an approval typed before the hive first sees the new head must be repeated would save a confused re-approve later.
Checks: only Sign-off survives the squash is CANCELLED (same as the other open v5 PRs this hour β looks like a runner/concurrency cancel, not this diff).
β hive: agent=reviewer backend=copilot model=claude-fable-5.1 copilot=1.0.88
|
Needs a decision, not a merge:
Head: verdict requires_human @ab64a95, 65/65 checks green, DCO yes, soak 0s of 30m0s. What to do: approve this head ( How to release it:
Clear the cause and the hive re-evaluates on its next pass; the stage moves to |
|
/hive decision approve Approving head |
clubanderson
left a comment
There was a problem hiding this comment.
Owner approval for head ab64a95 β see /hive decision approve above. Fail-closed binding of unmarked approvals to the observed push time, with tests.
|
LGTM label has been added. DetailsGit tree hash: 5d189e10540a5679a628c74e3d9cae7eec9564ef |
|
Decision recorded by @clubanderson for head |
β¦binding The changelog fragment and headObservedAt doc comment now say that the binding time is pinned on the sweep's first observation of the head, so an owner approval typed between the push and that sweep is judged stale and must be re-typed. Fail-closed by design; code is unchanged. Hive-Fix-Attempt: 1/6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com>
|
New changes are detected. LGTM label has been removed. |
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
There was a problem hiding this comment.
Reviewed (correctness, delta ab64a95β700a640) β no blocking findings.
Delta is documentation only: headObservedAt logic in src/pkg/github/automerge/automerge_head_age.go:118-134 is byte-identical to the prior head; only the doc comment (lines 112-115) and the changelog fragment changed.
Prior finding re-checked β approval typed between push and first sweep is discarded (automerge_head_age.go:134, observe pins PR updated_at on first sight): behaviour still present, but it is the fail-closed choice recorded in #14754 ("Decision taken by hive: fail-closed on the pinned observation time β¦ rather than querying the timeline API") and is now stated in the code comment and changelog. In-contract; the approver re-types. No longer blocks.
β hive: agent=adjudicator backend=copilot model=claude-fable-5.1 copilot=1.0.88
There was a problem hiding this comment.
Reviewed (intent-alignment, delta ab64a95β700a640) β no findings from this perspective.
Prior finding (approval typed between push and first sweep is discarded, automerge_head_age.go:117) is still present in behavior but is now documented as the deliberate fail-closed trade-off in the function comment (automerge_head_age.go:111-115) and the changelog fragment; this matches the decision recorded in #14754 ("fail-closed on the pinned observation time β¦ rather than querying the timeline API"), so it no longer blocks on intent.
β hive: agent=adjudicator-3 backend=copilot model=claude-fable-5.1 copilot=1.0.88
There was a problem hiding this comment.
docs-currency (delta ab64a95β700a640; read at 700a640)
Previous finding (automerge_head_age.go:117, approval typed before first observation discarded): now documented as deliberate fail-closed in the doc comment (automerge_head_age.go:111-115) and the changelog fragment, matching the decision recorded in #14754 β resolved as intended behavior.
One operator-facing doc still describes the old binding:
src/docs/hive-merge.md:495-496β "a trusted merger's own/hive decision approvecomment posted after the head" β after this PR the comment must post-date the hive's first observation of the head, not the push; an approval typed between push and sweep is treated as stale and must be re-typed (per changelog.d/fixed-workflow-fence-approval-head-binding.md). Suggest: "β¦posted after the hive first observed the head (an approval typed between the push and the hive's next sweep is treated as stale; re-type it)".src/docs/operator-reference.md:450("bound to it") could take the same clause.
β hive: agent=ci-maintainer backend=copilot model=claude-fable-5.1 copilot=1.0.88
|
Approval recorded by @clubanderson for head Recorded from the hive dashboard. |
β¦ad, not the push hive-merge.md and operator-reference.md still described the pre-#14754 binding ('posted after the head' / 'bound to it'). State that an unmarked /hive decision approve binds to the PR updated_at pinned on the hive's first sweep of the head, so an approval typed between the push and that sweep is stale and must be re-typed. Hive-Fix-Attempt: 2/6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: scanner <280983584+hivecommons-hive[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Reviewed (style) β approve; three low-severity advisories, none blocking.
-
Naming still says
headPushedAton the path the fix moved off it.src/pkg/github/automerge/automerge_contributor_workflow_approval.go:112bindsheadPushedAt := c.headObservedAt(...), passes it intoworkflowApprovalCommentActor(..., headPushedAt time.Time, ...)(line 133), and that function's doc (line 132) still says "posted after the head (headPushedAt, when known)". The whole point of #14754 is that this value is notheadPushedAt; a reader of the callee alone will assume the committer-date clock is still in play. Rename the local/param toheadObservedAtand update the line-132 comment. -
Duplicated pin logic.
automerge_head_age.go:118-131(headObservedAt) re-implements theupdated_atextraction, tracker selection,nowselection andobservecall already atautomerge_head_age.go:156-168(headPushedAtWith). A small shared helper (e.g.pinnedHeadObservedAt(tracker, key, pr, now)) returning the pin would letheadPushedAtWithbuild on it and keep the two clocks visibly derived from one source. -
Changelog fragment has no issue link.
changelog.d/fixed-workflow-fence-approval-head-binding.md:1omits#14754, whilechangelog.d/README.md("what changed, why an operator cares, issue links") and the two doc edits in this PR (src/docs/hive-merge.md:501,src/docs/operator-reference.md:450) all cite it.
β hive: agent=ci-maintainer backend=copilot model=claude-fable-5.1 copilot=1.0.88
There was a problem hiding this comment.
Reviewed (docs-currency) β one wording-accuracy note, non-blocking.
src/docs/hive-merge.md:498-500says an unmarked approval "typed between the push and that sweep is judged stale and must be typed again". The code is less strict than that:HeadFirstSeenTracker.observe(automerge_head_age.go:46-60) pins the PRupdated_aton first sight, and the approval comment is itself a mutation that movesupdated_atto its owncreated_at;workflowApprovalCommentActor(automerge_contributor_workflow_approval.go:144) accepts!created.Before(headPushedAt), so an approval that is the last mutation before the first sweep still releases the fence. It is only stale when another comment/label lands after it and before the sweep. The changelog fragment states the mechanism correctly ("any comment or label that lands between the push and that sweep moves it later"); suggest mirroring that qualifier in hive-merge.md and operator-reference.md:450 so operators aren't told to always re-type.
Code/doc parity otherwise verified: headObservedAt zero β binds nothing (automerge_contributor_workflow_approval.go:144), headPushedAt untouched for the soak gate, 085 note at automerge_stage_human.go:206 remains accurate.
β hive: agent=adjudicator backend=copilot model=claude-fable-5.1 copilot=1.0.88
|
Thank you for your contribution! Your PR has been merged. We'd love to hear how your experience was: share feedback |
Security Fix
workflowFenceApprovaltreated a typed/hive decision approve(or an unmarked dashboard/approve) as bound to the current head when the comment post-datedheadPushedAt.headPushedAtserves the min_head_age soak gate and, while that window is open, prefers the head commit's committer date β a value the pusher's git writes. A later push with a backdated commit therefore inherited an approval given on an earlier head and released the workflow-path fence for content the approver never saw.Why this shape: the soak gate's tolerance for an older committer date is a convenience the operator accepted (it can only shorten a wait), but reusing the same clock as an authorization binding turns that tolerance into a way to re-attribute a person's approval. The new
headObservedAtreturns only the PRupdated_atpinned on the head's first observation β server-side, never earlier than the push β andheadPushedAtis left untouched for the gate it was built for. Fail-closed by construction: a restart can only over-hold, and the merger re-approves the head.The APPROVED-review path (bound by
commit_id) and marker-bearing dashboard approvals were already safe and are unchanged.Test:
TestContributorLaneWorkflowFenceApprovalIgnoresCommitterDateruns withMinHeadAgewider than the head's observed age so the committer-date branch is live, a commit claiming 10:00 against a head GitHub first showed at 12:40, and asserts a 12:00 approval leaves the fence armed while a 12:50 one releases it. The first two cases fail without the change.Fixes #14754
Filed by sec-check agent (ACMM L6 β full mode)
β hive: agent=sec-check backend=copilot model=claude-fable-5.1 copilot=1.0.88