HOMELAB-1679: feat(plane,forgejo): verification closure — plane ticket/epic boundaries + forgejo landing lock #51
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1679-closure"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Converts three prose rules into fail-closed tool-layer boundaries (Lab Director Contract §5: boundaries, not prompts). Design:
.rpiv/artifacts/research/homelab-1679/closure-boundaries-design.md. No override parameter exists on any boundary; humans keep the Forgejo UI merge path.Boundary 1 — ticket closure (
plane_update_item)A transition to a state in the
completedgroup (Done and any future completed-group state) is refused unless the ticket's newest comment carries the verification marker: a## Verificationheading line (/^#{1,3}\s*verification\b/iafter HTML→text per-line), an<h1-3>Verification</h1-3>element, ordata-ld-verification. Newest-comment rule: re-opened work appends newer comments, so a stale verification cannot satisfy a later Done. The refusal names the ticket and prints the exactplane_commentcall to fix it. The check runs before the PATCH, so a refused transition changes nothing.Boundary 1 requires the states cache to be warm, and it is now warmed lazily (
ensureStates) instead of throwing "Project cache not loaded" — a cold cache can no longer turn a completion transition into an unchecked one.Boundary 2 — epic-Done census (
plane_update_item)A ticket with
parent_idchildren cannot go Done unless every child is (a) in a completed-group state and (b) carries the Boundary-1 marker as its newest comment. Refusals cite each blocking child:HOMELAB-1800 [In Progress] not in a completed-group state/HOMELAB-1801 [Done] completed but unverified — its newest comment has no verification marker, plus a count-N-of-M header. Success returns the census it verified (epic census passed: 2 children, 2 closed and verified (…)+details.ldClosure.census) so the Done is attributable.Epic-shaped tickets (
(epic)in title/description) with no parented children are refused loudly ("no machine-readable children; re-parent …") — no auto-pass.listChildrenfilters client-side because Plane ignoresparent_idas a list filter (verified read-only against the homelab workspace: a bogusparent_idreturned all 1441 items); the child field isparent.Boundary 3 — landing lock (
forgejo_merge_pr)success, read through the HOMELAB-1736 endpoint-fallback pattern (/commits/{sha}/statusprimary,/statuseslist fallback with the degradation surfaced in the result).pending→ wait-for-CI refusal;failure/error/warning→ refusal naming the non-green contexts; no status at all → refusal (fail closed; the message points repos without CI at the UI merge path).## Review/## Landing review …heading, or averdict:line) must post-date the head commit (commit date from/commits/{sha}, fallback the PR commit list); an earlier round's verdict is stale.blockerfinding refuses the merge and is cited with its id.The design left the blocker grammar open, so the lock defines one and the refusal message teaches it:
blocker <id>findings,resolved <id>/fixed-in <id>resolutions, negated statements ("no blockers", "blockers: none") ignored. Every leg fails closed when its evidence cannot be read.HOMELAB-1799
createRelationnow writesissues(201) instead of the read-shaperelated_issues(400s). Covered by a test that would fail against the old payload.Tests / CI
New scaffolding for
plane/andforgejo/(package.json,tsconfig.json, lockfiles — mirroring the other gated packages), plus two CI jobs. All API traffic is a route-table fake; no network, no Plane/Forgejo writes.plane/tests/closure.test.ts— 19 tests: marker acceptance/rejection, stale-verification refusal, newest-comment ordering, census refusal citing children, cancelled child, epic-shaped no-children, non-completion transitions untouched, createRelation payload.forgejo/tests/landing.test.ts— 23 tests: red/pending/absent CI, fallback + double-failure, missing/stale verdict, review-body verdict, open blocker + resolved blocker, wrong-id resolution, negated blockers, pure state derivation.npx tsc --noEmitclean for both packages; two dormant type errors inplane/index.tsand three inforgejowere fixed so the packages can be gated at all.Deviations / interpretations (design is silent, recorded here)
<h1-3>Verification</h1-3>element counts, because Plane stores comments as HTML.## Landing review …(the shape the landing pipeline actually posts, verified against PR #48) alongside the design's## Review/verdict:forms.cancelledchildren block the census (the design says completed-group; "every child dispositioned" would have been the looser reading). The refusal's remedy line names re-parenting, so this cannot dead-end silently.verdict.at >= headCommitAt(second-resolution Forgejo timestamps); a verdict from an earlier round is strictly older than a later commit, so>=cannot resurrect one.Honest limits
blockers_countfield) is not caught. Canonicalblocker <id>+resolved <id>markers are always caught.Boundary 3 turns the AGENTS.md landing prose ("merge on green CI + clean review verdict") into a fail-closed precondition on the agent tool. Before the merge executes: 1. CI leg — the PR head sha's commit status must be `success`. The combined /status endpoint is primary; if it is unavailable the /statuses list answers instead and the degradation is surfaced in the result (HOMELAB-1736 endpoint-fallback pattern). `pending` -> wait-for-CI refusal; `failure`/ `error`/`warning` -> refusal naming the non-green contexts; no status at all -> refusal (fail closed, with the UI-merge path for repos without CI). 2. Review-verdict leg — a verdict (`## Review`/`## Landing review …` heading or a `verdict:` line) must post-date the head commit; a verdict from an earlier round does not cover the current diff. 3. Blocker leg — an open `blocker` finding refuses the merge and is cited, with its id, in the refusal. The blocker grammar the design left open is defined in forgejo/api.ts and taught by the refusal message: `blocker <id>` findings, `resolved <id>` / `fixed-in <id>` resolutions, negated statements ("no blockers") ignored. Humans keep the Forgejo UI merge path — the lock governs autonomy, never the operator. There is no override parameter. forgejo/ gains typecheck + vitest scaffolding with mocked-fetch tests for every refusal leg; two dormant type errors are fixed so the package can be gated. CI: plane + forgejo jobs added (additive one-line job copies).HOMELAB-1679: verification closure — plane ticket/epic boundaries + forgejo landing lockto HOMELAB-1679: feat(plane,forgejo): verification closure — plane ticket/epic boundaries + forgejo landing lockForgejo serves issues/{n}/comments oldest-first with a 50-item clamp, so a single page would miss a verdict on a long-running PR and refuse a merge that should pass. listAllPRComments pages (6-page cap) and a test pins it with 50 filler comments ahead of the verdict.Builder handoff — CI green, PR ready for landing review
Head sha:
ea299f1b85· CI run #96: 10/10 jobs green (incl. the two new legsplane,forgejo).Read-only live check of the lock's own data source (the CI leg of Boundary 3):
So
forgejo_merge_pron this PR will seesuccessfor the head sha — the lock is satisfiable on this repo, not just on fixtures.What the landing reviewer should check
plane/api.ts+plane/index.ts: boundaries 1 and 2 run before the PATCH (a refusal cannot half-write a transition); no override parameter;listChildrenfilters client-side because Plane ignoresparent_idas a list filter (verified read-only against this workspace — a bogusparent_idreturned all 1441 items); the child field isparent.forgejo/api.ts+forgejo/index.ts: three legs, each fail-closed when its evidence is unreadable; the/statusesfallback is HOMELAB-1736-shaped and the degradation is surfaced in the result rather than hidden.Notes for the landing worker
ea299f1's commit date, and no unresolved finding on ablocker <id>line.## Review/## Landing review …heading or averdict:line, which is what the pipeline already posts (checked against PR #48's real verdict comments).cancelledchildren blocking the census (design says completed-group), and the TOCTOU window between the head-sha check and the merge call (Forgejo's merge endpoint takes no sha guard) which is left as a follow-up.Landing review (fresh-context)
Fresh process, primary sources only:
git diff origin/main...ea299f1f(headea299f1b85dad60f32897c7d24fb885acd0f8da9), the frozen design.rpiv/artifacts/research/homelab-1679/closure-boundaries-design.md, HOMELAB-1679 / HOMELAB-1799, plus the live Plane + Forgejo APIs as a read-only oracle. Implementation summary not used.Verdict: merge. Findings tagged
blocker/concern/suggestion— 0 / 4 / 5.Traced as fail-closed (the three boundaries)
plane/index.ts:717-740runsenforceClosureBoundariesbeforeupdateWorkItem; refusal is a thrown error, no PATCH.plane/tests/closure.test.ts:169,182assert zero PATCH calls on refusal. No override parameter exists on the tool or the predicate (plane/api.ts:494-534).VERIFICATION_HEADING_REis^#{1,3}\s*verification\bapplied per line ofhtmlToLines()(plane/api.ts:419-463), so"packing verification"/"pending verification of the fix"do not match (tests atclosure.test.ts:103). Stale-verification refusal is by newest comment (closure.test.ts:109).enforceClosureBoundariescannot miss the newest comment; the code also sorts defensively.evaluateEpicCensusblocks on!completed || !verified(plane/api.ts:544-576); empty census refuses; a child state absent from the cache yieldsnull !== "completed"→ blocked; every child's project is warmed viaensureStates, which throws rather than judging blind (plane/index.ts:151-160,224-228).listChildrenis correct against live Plane. I re-verified the claim independently: the project list response does carryparent(30 parented items on page 1 of 1000), andlistAllWorkItemscursor-pagination returns all 1445 items over 15 pages. All 9(epic)-hinted tickets in HOMELAB have parented children (4-11 each), so the fail-loud childless-epic path has zero false positives today.assertLandingLockruns beforemergePR(forgejo/index.ts:475-488); refusal = no POST to/merge(testslanding.test.ts:246,258,272). Primary status endpoint is live-correct:/commits/ea299f1b85…/status→{"state":"success","total_count":10}; head sha resolved live (pulls/51→head.sha).fetchwith route-table fakes;npm ci-able scaffolding mirrorstodo/skill-args. Local re-run on the PR worktree:plane19/19 pass,forgejo24/24 pass,tsc --noEmitclean for both.Concerns (may merge)
forgejo/api.ts:467—getCommitcallsGET /repos/{o}/{r}/commits/{sha}, which does not exist on Forgejo (live: 404page not foundfor both the short and full head sha). The real route is/repos/{o}/{r}/git/commits/{sha}(live 200). So the "primary" branch of the staleness leg is dead code and every merge eats a wasted 404 before falling through tolistPRCommits(live 200, works). The docstring's "Primary:/commits/{sha}" is false, andforgejo/tests/fake.ts:207matches/commits/{anything}$, so no test can ever catch this.forgejo/api.ts:518,525— the/statusesfallback derives state from all status entries, not the newest per context. Live for the head sha:/statusesreturns 30 entries across 10 duplicated contexts, 20 of thempending(superseded jobs from runs #95/#93) → the fallback derivespendingand would refuse a merge the combined endpoint correctly callssuccess. Fail-closed direction, so not a blocker, but the documented "degraded read" is effectively unusable for this repo; group bycontextand keep the newest entry per context.plane/index.ts:635—plane_create_itemstill writes anystate, including a completed-group one, with no B1 check. The design scopes B1 to transitions, so not a deviation, but it is the only remaining path into a completed-group state that skips the check.docs/plane.md:18("No npm dependencies") anddocs/forgejo.mdare now stale relative to this diff: the extensions havepackage.json+package-lock.jsonand CI gates, and neither the tools tables nor the Gotchas sections mention the closure boundaries or the landing lock — the most behavior-changing edits in the PR.Suggestions
forgejo/api.ts:565— when the head sha is not found in the PR commit list, the fallback pickscommits[commits.length - 1], which live is the oldest commit (the list is newest-first).commits[0]is the safe default; the current one would weaken the staleness leg if it ever triggered.forgejo/index.ts:98— the diff joined thefmtRundoc comment onto the function line and dropped the blank line; unintended cosmetic churn.VERIFICATION_TEMPLATEand the refusal text embed literal<h2>Verification</h2>/data-ld-verification(plane/api.ts:428-436), so a comment quoting the refusal would itself satisfy B1, and a comment that merely documents the verdict grammar satisfies B3. Inherent to a marker-based boundary the design ratified — noted, not blocking.## Review/verdict:comment satisfies it. Consistent with the design (independence lives in dispatch discipline); worth stating in the docstring so the lock is not read as stronger than it is.forgejo/tests/landing.test.tshas 23 tests; it has 24 (19 + 24 = 43 total). Trivial, and the body's own total is right.Operational note for the fleet (not a defect)
Sampling live: 0 of the 12 most recently completed HOMELAB tickets have a newest comment matching the marker (real shapes:
CLOSING VERIFICATION — …,VERIFIED — PROVEN …,Closing as delivered …). So from the moment this lands, every Done transition — and every LD0-LD8 epic census (LD0 has 4 already-Done children that still need re-derived markers) — will be refused until a conforming## Verificationcomment is posted. That is exactly the ratified design's intent, and the refusal self-teaches the remedy; announcing it to the fleet will avoid a wave of "the tool is broken" reports.Merging on green CI + no blockers.