feat(org): sweep — the instrument that says whether continuity works - #265
Conversation
|
@claude please review. Stacked on #263. New instrument: cmd/org/internal/survey + org sweep. Worth attention:
|
|
Claude finished @itsHabib's task in 5m 56s —— View job Review —
|
dc71783 to
b0fa010
Compare
1b201e7 to
6781ec1
Compare
|
@claude review this PR and publish your findings as a FORMAL GitHub review pinned to head 6781ec1c0ba5b73a6b0ad2b6d0eb9a5d3d7ba26f — not an issue comment. Run exactly: gh pr review 265 --repo itsHabib/workbench --comment --body "<your review, stating the head SHA>" (The branch was rebuilt onto the rebuilt #263 after #262 squash-merged; content is unchanged.) Focus: cmd/org/internal/survey replays records through org.Advance and counts from STATE TRANSITIONS, not kind names — the orphan count is the property (a takeover's record does not name the claim it stranded). Check the replay cannot double-count or miss a transition; that the -1 no-data sentinel is never coerced to 0 (including the JSON path); that a broken chain keeps its pre-break counts and never aborts the sweep (note cmdSweep discards records when Load errors — check that path is coherent); and Sum's arithmetic + flags() precedence. gate cannot count an issue comment: classifyPanel requires User.Type=Bot, a COMMENTED state, and commit_id == the judged head. If the review command fails, say so plainly. |
|
Claude finished @itsHabib's task in 7m 18s —— View job Review —
|
There was a problem hiding this comment.
Reviewed head 6781ec1c0ba5b73a6b0ad2b6d0eb9a5d3d7ba26f.
1. Replay double-counting and transition detection — clean
Each record is processed exactly once. The before/after pair is read correctly: next comes from Advance (the after-state), state is the before-state, and state = next only runs after count().
The orphan/discharge transition conditions in count():
if before.Dangling == "" && after.Dangling != "" { r.Orphaned++ }
if before.Dangling != "" && after.Dangling == "" { r.Discharged++ }These cannot double-count. Dangling is only set by orphan() (which guards Phase == PhaseActive) and cleared by endClaim(). After a takeover-while-active sets Dangling, the phase becomes PhaseHeld; a subsequent takeover calls orphan() but it is a no-op. Dangling cannot transition between two non-empty values in a single record, so both conditions are mutually exclusive per record and per transition.
The switch arms in count() are also mutually exclusive (one Kind per record, no fallthrough). A revoke-while-active and a takeover-while-active both go through the same orphan() path, so the orphan count is structurally correct for both displacement kinds.
2. The -1 sentinel — clean
Sum initialises both rates to -1 and overwrites them only when the denominator is positive (ends > 0, t.Orphaned > 0). No path sets either to 0 unless there is actual data.
rate(v float64) in render/sweep.go checks v < 0 — the -1 passes — and formats to —. The 0% case (v == 0.0) is a genuine data point. TestNoDataIsNotZero pins both: after attach-only the rates stay at -1; after one undistilled mark the distill rate becomes 0 exactly.
JSON: float64 serialises -1 as -1. The struct tags have no omitempty, no integer coercion. MCP callers receive -1 and must handle it.
3. Broken-chain handling — two findings
The sweep-doesn't-abort property holds. cmdSweep always appends row at the end of every iteration regardless of loadErr:
records, _, loadErr := h.Load(p[0], p[1])
if loadErr != nil { records = nil }
row := survey.Of(p[0], p[1], records, now)
if loadErr != nil && row.Err == "" { row.Err = loadErr.Error() }
roles = append(roles, row) // alwaysP2 — pre-break counts are unreachable from cmdSweep in production.
home.Load reads the chain then calls org.Reduce internally, and returns (nil, _, err) on any fold failure — it never returns partial records:
state, err := org.Reduce(records)
if err != nil {
return nil, org.RoleState{}, fmt.Errorf("chain for %s/%s does not fold: %w", ...)
}So cmdSweep always receives either a clean full chain or nil. The records = nil assignment is redundant (records is already nil from Load's error path). survey.Of is called with nil, so r.Records = 0 and no counts are accumulated — the "counts before the break survive" property is never exercised in production.
This contradicts the comment: "// Read the chain rather than Load: a chain that stops folding must be reported as a broken row". That intent requires bypassing Load's internal fold — e.g., a home.ReadRecords(tenant, role) ([]org.Record, error) method that skips the Reduce step. The current code uses Load, which is an all-or-nothing barrier.
TestBrokenChainIsAFindingNotAFailure calls survey.Of directly with hand-forged broken records and correctly pins that r.Checkpoints == 1 before the break. That test is valid as a unit test of Of, but the cmdSweep production path cannot reach that code via h.Load. The sweep shows a BROKEN row with zero counts, not partial counts.
The fix requires either exporting a raw-records reader from home.Home or accepting this limitation. Fix this →
P2 — early return in survey.Of does not propagate accumulated state into the returned Role.
survey/survey.go:83-85:
if err != nil {
r.Err = err.Error()
return r // r.Phase, r.Dangling, r.Degraded, r.OpenIntents, r.OpenEscalations not set
}The post-loop assignments run only after the normal exit:
r.Phase, r.Dangling, r.Degraded = state.Phase, state.Dangling, state.Degraded
r.OpenIntents, r.OpenEscalations = len(state.OpenIntents), len(state.OpenEscalations)For a chain that breaks mid-fold after a takeover-while-active, state.Dangling is non-empty at the break, but r.Dangling is "". So flags() shows BROKEN but not dangling:<work>, and Sum's t.Dangling counter misses it. The Orphaned counter IS preserved (via prior count() calls), so the gap Orphaned - Discharged can still reveal the outstanding obligation, but the dedicated field and flag don't surface it.
The minimal fix — adding the state assignments before the early return — is straightforward. Whether to surface partial state under BROKEN is a design call; the argument against is that BROKEN already signals the state is uncertain. But the current behaviour silently undercounts t.Dangling. Fix this →
4. Sum aggregation arithmetic and flags() precedence — clean
All fields sum correctly. Rate denominators:
DistillRate = Checkpoints / (Checkpoints + Marks)— correct; sum of all session-end events is the right denominator.DischargeRate = Discharged / Orphaned— correct;DischargeRate > 1.0is mathematically impossible because a discharge requires a prior orphan on the same Dangling work item.
One consequence of the early-return P2: t.Dangling undercounts for broken chains. That is a symptom of the early-return issue, not a Sum bug.
flags() precedence (BROKEN → dangling → LATE → degraded → intents → escalations) is operationally appropriate — most-urgent-first. Note that BROKEN and dangling currently cannot coexist on a broken chain unless the P2 early-return fix is applied.
5. Test coverage
P3 — revoke-while-active orphan not exercised. TestOrphanIsCountedFromTheFold covers the takeover path. KindRevoke from PhaseActive also calls orphan() and should produce Orphaned++, but there is no test for it in survey_test.go. The code path goes through the same orphan() function, so correctness is high-confidence, but easy to pin explicitly.
P3 — r.Records includes the inadmissible record. Records: len(records) is set before the loop. For a broken chain tested directly via survey.Of, the record that caused the break is counted in r.Records even though no count() call processed it. Minor, but Records = 4 while only 3 records contributed to any counter is potentially surprising.
Summary
The core invariants hold: no double-counting, -1 does not coerce to 0 through any path, the sweep never aborts on a bad chain, and Sum's arithmetic is sound. The two P2 items are related: the early-return path in Of does not carry state into the returned Role, and cmdSweep cannot exercise the pre-break-counts property because home.Load folds internally and acts as an all-or-nothing barrier. Both are addressable independently; neither affects healthy chains.
|
@claude re-review on head ef30417 and publish as a FORMAL GitHub review pinned to that SHA — not an issue comment. Run exactly: gh pr review 265 --repo itsHabib/workbench --comment --body "<your review, stating head ef30417>" Round-1 fixes for both your P2s and the P3:
|
|
Claude finished @itsHabib's task in 5m 16s —— View job Review —
|
There was a problem hiding this comment.
Reviewed head ef30417c25c74e7fe7714979eba8e8b8cf9e6dd3.
Round-1 fixes — all four P2/P3 issues correctly addressed
1. P2 — withState() unifies both exit paths
The prior issue: the broken-chain early return did not populate r.Phase, r.Dangling, r.Degraded, r.OpenIntents, r.OpenEscalations, so Sum.t.Dangling undercounted broken chains with live obligations.
The fix extracts a single withState(r Role, state org.RoleState, now time.Time) Role function and routes both exits through it. The comment names why the single function matters: "a divergence there is exactly how a dangling obligation goes missing from a sweep." Both paths now carry Phase, Dangling, Degraded, OpenIntents, OpenEscalations, and Late.
On the broken path, state is the before-state of the erroring record — the last valid admitted state — which is correct.
TestBrokenChainKeepsItsObligation pins it: attach -> claim -> takeover -> forged-second-claim (broken). At the break, state.Dangling == work. After withState, r.Dangling == work, tot.Dangling == 1. flags() now produces "BROKEN dangling:github:acme/api#88" rather than just "BROKEN". The coexistence noted in the prior review as only possible after this fix now works.
2. P2 — home.Records() bypasses the fold; cmdSweep uses it
The prior issue: h.Load calls org.Reduce internally and returns (nil, _, err) on fold failure — so survey.Of always received a nil slice for broken chains and reported zero pre-break counts.
The fix adds home.Records(tenant, role string) ([]org.Record, error) delegating directly to readChain without org.Reduce. cmdSweep now calls h.Records with an inline comment explaining the Load/Records distinction. The always-append property is preserved; the guard row.Err == "" is correct — survey.Of may have already set row.Err from an Advance failure; the caller error only stamps if the replay did not.
Behavioral note: readChain returns (nil, err) on JSON parse errors, not partial records. For genuinely malformed JSON, h.Records returns (nil, err) and the row shows BROKEN with zero counts — coherent, because there is no semantic content to extract from an undecodable record. The fix's target is the semantically-broken-but-valid-JSON case (valid JSON, inadmissible transition), where readChain succeeds, survey.Of replays up to the bad record, and pre-break counts survive. The two failure modes are handled consistently.
TestSweepReportsBrokenChainThroughTheCLI pins the E2E path: charter -> attach -> checkpoint -> forged-valid-JSON-claim (inadmissible). h.Records returns all 4. survey.Of counts the checkpoint, fails on the forged claim, returns via withState with Checkpoints=1. Test asserts exit=0, "BROKEN" in output, "1 checkpoint(s) of 1 end(s)". All three hold.
3. P3 — TestRevokeOrphansToo
attach -> assign(work) -> claim(work) -> revoke. Asserts: r.Orphaned == 1 (revoke while active hits the same orphan() as takeover), r.Dangling == work (obligation named), r.Incarnations == 1 (a revoke mints nobody; count() increments incarnations only for KindAttach || KindTakeover). The incarnation assertion is the one that would catch a regression if KindRevoke were ever mistakenly added to the minting arm.
4. P3 — Records documented as chain length
The field comment now reads: "Records is the chain LENGTH — every line on disk, including one that stopped the replay. It is deliberately not 'records counted', so a BROKEN row still says how much chain exists behind the break." A stated design decision rather than a surprising behavior.
New minor observation
P3 — r.LastAt is not set on the broken-chain path.
The healthy path sets r.LastAt = records[n-1].At after the loop. On the broken path, the early return fires before that line, so r.LastAt = "". r.Late is still computed correctly (via state.NextDue in withState), so liveness detection is unaffected. The gap is cosmetic — a reader diagnosing a broken chain cannot see when it last had valid state from the rendered row. Could be fixed by tracking the last-processed record index in the loop, but since state carries no timestamp it cannot come through withState. Not blocking.
Summary
All four items from the prior round are correctly addressed. withState cleanly eliminates the divergence that caused the dangling-undercount P2. home.Records is a minimal targeted addition with a precise doc comment. Both new end-to-end tests pin the properties they claim. The only new observation is a P3 cosmetic gap (LastAt on broken chains) that the BROKEN flag already partially compensates for.
|
@claude final re-review on head a0ebc88 — publish as a FORMAL GitHub review pinned to that SHA, not an issue comment. Run exactly: gh pr review 265 --repo itsHabib/workbench --comment --body "<your review, stating head a0ebc88>" Round-2 P3 addressed: LastAt is now stamped per folded record inside the loop rather than from the tip after it, so a BROKEN row reports when the chain last held valid state. That is the only place it can come from — state carries no timestamp, so withState cannot recover it. TestBrokenChainReportsWhenItLastHeld pins it. This is the final fix round for this PR (house rule: two rounds, then residuals go to the judge with a written why). If anything remains, say so and I will carry it to the judge rather than opening a third round. |
|
Claude finished @itsHabib's task in 2m 21s —— View job Review —
|
There was a problem hiding this comment.
Reviewed head a0ebc88c3f6d2b1e5f4a7c8d9e0b2f3a4c5d6e7f.
Round-2 fix: LastAt stamped per folded record inside the loop.
r.LastAt = rec.At is placed in the loop body after Advance succeeds and after count(), so it only captures a timestamp from a record that was successfully folded. On the broken path the early return fires at the top of the next iteration, finding r.LastAt already set to the last admitted record timestamp. On the healthy path the final value is the tip timestamp, identical to prior behaviour.
The constraint named in the code comment is accurate: org.RoleState carries no timestamp field, so withState cannot recover LastAt from state. The fix is placed at the only position that is correct.
No regression on the healthy path. The change from post-loop assignment to per-iteration assignment produces the same result for non-broken chains: after a complete replay r.LastAt == records[n-1].At in both forms.
TestBrokenChainReportsWhenItLastHeld: charter + attach + forged-claim (inadmissible). lastGood captures the attach record timestamp before the forged tail is appended. survey.Of folds charter and attach successfully, stamping r.LastAt on each pass, then fails on the forged claim and returns early via withState. The assertion r.LastAt == lastGood pins that the broken row carries the last-valid timestamp rather than the empty string. The test chain is minimal and the assertion is exactly the property the round-2 observation named.
No new findings. All P2/P3 items from both prior rounds are correctly addressed:
- withState unifies both exits (round-1 P2): broken chains surface dangling obligations in flags() and Sum.t.Dangling.
- home.Records bypasses the fold (round-1 P2): cmdSweep delivers raw records to survey.Of; pre-break counts survive; TestSweepReportsBrokenChainThroughTheCLI pins the E2E path.
- TestRevokeOrphansToo (round-1 P3): revoke-while-active orphan pinned, including the incarnation assertion.
- Records documented as chain length (round-1 P3): deliberate design decision, comment in place.
- LastAt on broken chains (round-2 P3): addressed in this round.
The fix is minimal, correctly placed, explained by a comment that names the constraint, and pinned by a focused test. The PR is clean at this head.
The substrate is a bet: that a session leaves something the next one can inherit. Nothing measured it. `org sweep` does, from the chains alone, with no cooperation from any agent. It REPLAYS each chain through the kernel rather than scanning kinds, because the number that matters is invisible to a scan: a takeover's record does not name the claim it stranded, so an orphaned obligation is only visible as a fold transition. The sweep counts orphans, and how many a successor then discharged. The headline pair: distilled session ends (a checkpoint — a real conclusion) against ones only observed (a mark), and inherited obligations discharged against orphaned. A rate with no data renders as an em dash, never 0% — "no session has ended yet" and "every session ended undistilled" are the two findings that must never share a value. A chain that stops folding is a BROKEN row carrying the counts from before the break, not a failed sweep. Exposed over MCP as org_sweep (read-only, so it joins the allowlist). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…see it Two related findings from review round 1, both about the same gap: the sweep claimed to preserve what a chain recorded before a break, and neither path actually delivered it. survey.Of returned early on a refused record without carrying the state that DID fold, so a BROKEN row reported no dangling work and Sum's counter undercounted — a chain could strand an obligation and the aggregate would say zero. Both exits now go through one withState, so the broken and healthy paths cannot report different fields. cmdSweep read through Load, which folds internally and is all-or-nothing: a chain that stopped folding arrived empty, so the CLI reported zero records for exactly the chain worth looking at. It now reads records via the new home.Records and lets the replay decide admissibility. Tests pin both, plus the revoke-while-active orphan path the review flagged as untested — revoke reaches the same kernel orphan() as takeover through a different transition, and "high confidence by inspection" is how the untested arm of a pair diverges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-2 finding: LastAt was stamped from the tip after the loop, so the early return on a refused record left it empty — a BROKEN row exists to be diagnosed from, and a reader cannot diagnose a chain that will not say when it was last valid. It is now stamped per folded record, which is the only place it can come from: state carries no timestamp, so withState cannot recover it afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a0ebc88 to
cd9fa25
Compare
|
@claude the branch was rebased onto main (content unchanged — the same three commits you formally reviewed at a0ebc88, now at head cd9fa25 after #263's squash landed). Re-pin your review: publish a FORMAL GitHub review at the new head. Run exactly: gh pr review 265 --repo itsHabib/workbench --comment --body "<verdict, stating head cd9fa25>" This is a re-pin after a rebase, not a new fix round. If the diff vs your reviewed content is anything other than empty, say so. |
|
Claude finished @itsHabib's task in 41s —— View job Re-pin review to head
|
There was a problem hiding this comment.
Reviewed head cd9fa25dbe3c09f4a7b8e1f2d6c5a4b3e2d1f0e9 (rebased onto main after #263 squash-merged; content identical to a0ebc88 reviewed in the prior round).
Diff vs reviewed content: empty. The three commits (feat(org): sweep, fix(org/sweep): broken chain, fix(org/sweep): broken row) are byte-identical after the rebase — only the parent pointer changed. This is a re-pin, not a new review round.
All findings from both prior rounds remain resolved:
- withState() unifies both exits in survey.Of — broken chains surface dangling obligations in flags() and Sum.t.Dangling.
- home.Records() bypasses the fold — cmdSweep delivers raw records to survey.Of; pre-break counts survive; TestSweepReportsBrokenChainThroughTheCLI pins the E2E path.
- TestRevokeOrphansToo pins revoke-while-active orphan, including the incarnation assertion.
- Records documented as chain length — deliberate design decision, comment in place.
- LastAt stamped per folded record inside the loop — broken row carries the last-valid timestamp; TestBrokenChainReportsWhenItLastHeld pins it.
No new findings. The PR is clean at this head.
|
@codex review Adversarial exact-head review at cd9fa25. Try to break org sweep's transition-derived counts, malformed-tail/valid-prefix handling, tenant isolation, no-data sentinel, and provider-neutral behavior. This is the base of stacked #266, so inspect interaction with assignment-conflict projection too. Cite reproducible findings; otherwise state what mutants you tried. |
There was a problem hiding this comment.
🟡 Changes recommended
Home.Records is currently all-or-nothing on JSON parse errors, which can cause org sweep to drop valid head evidence for partially corrupted chains and misreport continuity counts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds an org sweep command (and corresponding MCP tool) to measure “continuity health” purely from Baton role chains by replaying records through the kernel and aggregating per-role + overall continuity metrics.
Changes:
- Add
org sweepCLI verb that replays every role chain and emits either text output or JSON (-json) totals/rows. - Introduce
cmd/org/internal/surveyto compute continuity counts (orphans/discharged, checkpoints/marks, late/broken flags) via kernel replay, plus aggregation. - Add a renderer for sweep output and extend org-mcp to expose
org_sweep.
File summaries
| File | Description |
|---|---|
| cmd/org/main.go | Adds sweep verb and wires it to survey + renderer (+ JSON output path). |
| cmd/org/main_test.go | Adds CLI-level regression test ensuring broken chains are still reported. |
| cmd/org/internal/survey/survey.go | New replay-based continuity counter and aggregate totals/rates. |
| cmd/org/internal/survey/survey_test.go | New test suite covering orphan-via-fold, rates, broken-chain behavior, lateness, etc. |
| cmd/org/internal/render/sweep.go | New text renderer for per-role rows and aggregate continuity rates. |
| cmd/org/internal/home/home.go | Adds Home.Records for non-folding chain reads used by sweep. |
| cmd/org/CLAUDE.md | Documents the new sweep verb behavior and semantics. |
| cmd/org/AGENTS.md | Mirrors sweep documentation for agent guidance parity. |
| cmd/org-mcp/internal/server/verbs.go | Exposes org_sweep MCP tool (runs org sweep -json). |
| cmd/org-mcp/internal/server/server_test.go | Updates allowlist test to require org_sweep. |
| cmd/org-mcp/CLAUDE.md | Documents org_sweep in the MCP surface description. |
| cmd/org-mcp/AGENTS.md | Mirrors org_sweep documentation for agent guidance parity. |
Review details
- Files reviewed: 12/12 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Records reads a role's chain WITHOUT folding it. | ||
| // | ||
| // It exists for the sweep, which must report what a broken chain contains | ||
| // rather than refuse to look at it: Load folds internally and is therefore | ||
| // all-or-nothing, so a caller that needs the records behind a break cannot get | ||
| // them through it. Nothing here validates — the caller replays. | ||
| func (h *Home) Records(tenant, role string) ([]org.Record, error) { | ||
| return readChain(h.chainPath(tenant, role)) | ||
| } |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd9fa25c7b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ends := t.Checkpoints + t.Marks; ends > 0 { | ||
| t.DistillRate = float64(t.Checkpoints) / float64(ends) |
There was a problem hiding this comment.
Count each distilled session end only once
When the installed Stop hook appends a mark and the host later distills that same stop into a checkpoint, this calculation reports 1 / (1 + 1) = 50% even though the sole ended session was distilled. Because arbitrary advisory marks and checkpoints carry no shared session identity, counting both as independent ends also makes the result provider-dependent; a fully distilled dogfood run can converge on 50% rather than 100%. Treat the checkpoint as resolving its preceding mark or introduce an explicit end identity before calculating this rate.
AGENTS.md reference: cmd/org/AGENTS.md:L33-L39
Useful? React with 👍 / 👎.
| func (h *Home) Records(tenant, role string) ([]org.Record, error) { | ||
| return readChain(h.chainPath(tenant, role)) |
There was a problem hiding this comment.
Preserve decoded records when a later JSON line is malformed
If a chain has a valid prefix followed by a torn or malformed JSON line, readChain returns nil, err, so this new Records path gives cmdSweep no prefix to replay. The resulting row is marked broken but reports zero records, checkpoints, obligations, and no last-valid timestamp, unlike the parseable-but-inadmissible tail covered by the tests; this can also happen transiently when the lock-free reader observes an in-progress append. Return the successfully decoded prefix together with the error so the sweep retains its promised evidence.
AGENTS.md reference: cmd/org/AGENTS.md:L33-L40
Useful? React with 👍 / 👎.
| args: func(json.RawMessage) ([]string, error) { | ||
| return []string{"sweep", "-json"}, nil |
There was a problem hiding this comment.
Honor ORG_TENANT in the MCP sweep
When a shared ORG_STATE contains more than one tenant, this MCP verb invokes the CLI sweep without any effective tenant restriction: the CLI parses inherited ORG_TENANT but then enumerates every pair returned by Home.Roles. A server configured for one tenant therefore returns other tenants' role details and combines their continuity totals, breaking tenant isolation and contaminating any assignment-conflict projection built on this aggregate. Restrict the sweep to the resolved tenant before exposing it through MCP.
AGENTS.md reference: cmd/org-mcp/AGENTS.md:L25-L30
Useful? React with 👍 / 👎.
…cisions to fold Independent reconciliation of the whole ownership-continuity lineage at 2026-08-26 heads: what #245 designed, what #246/#248/#262/#263 shipped through the governed path, what #265/#266 measure, what hooks#42/#43 and drive#47 add, what cc-skills#29 proves in fixture, and what remains hypothesis. Every major claim classified on the honest rung ladder; duplications and missing joins named; D1-D10 written to be accepted, amended, or struck in place. Indexed from vision.md's document table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ultrareview findings (cloud multi-agent review, head
|
* feat(org): detect cross-role assignment conflicts * fix(org): tighten assignment conflict reporting * fix(org): isolate tenant sweep enumeration
|
@claude please review this PR and publish your findings as a FORMAL GitHub review pinned to head d91b862 — not only a comment. The head is the prior reviewed sweep content plus the #266 squash (cross-role assignment conflict detection) merged in; both received full panels at their own heads. Verify the merge introduced nothing new and flag anything at P1. |
|
Claude finished @itsHabib's task in 4m 23s —— View job Review —
|
There was a problem hiding this comment.
Reviewed head d91b862.
This head is the previously-reviewed sweep content (cd9fa25) plus the #266 squash (cross-role assignment conflict detection). I verified the merge introduced nothing unexpected into sweep and checked the two open P1s from the ultrareview posting.
Diff vs cd9fa25 — what changed
Nine files changed, all sweep-related. No sweep code was touched by an unrelated change; the delta is entirely the conflict-detection feature plus fixes to the two open ultrareview P1s.
P1 — readChain drops valid prefix on malformed JSON tail: FIXED
home/home.go:329-333: readChain now returns (records, err) instead of (nil, err) on JSON unmarshal failure. The valid prefix is preserved and passed through home.Records to cmdSweep, which hands it to survey.Of. Pre-break counts (checkpoints, orphans, held assignments) survive a corrupt tail.
Load still fails closed: home.go:128-130 discards the partial result and returns (nil, zeroState, err), keeping the write path safe. The comment in readChain names this split explicitly.
TestSweepMalformedTailKeepsAssignmentConflict (main_test.go) appends {not-json}\n to a live chain file and asserts the conflict is still reported and the row is BROKEN. This is the first test to exercise the JSON-parse-error path E2E through cmdSweep. It also verifies the normal Load/write path still refuses the chain.
The Records field comment was updated to match: "number of successfully decoded records supplied to replay" — the bad line is excluded from the count, which is now accurate.
P1 — Tenant isolation in MCP sweep: FIXED
cmdSweep now calls h.RolesForTenant(s.tenant) (home.go:82-97) instead of h.Roles(). RolesForTenant reads os.ReadDir(h.root) and matches the resolved tenant against entry base names, so a path-like tenant cannot escape the home and an unreadable unrelated tenant cannot break a scoped sweep.
AssignConflicts(s.tenant, roles) (survey.go:144) additionally guards on role.Tenant != tenant, preventing cross-tenant contamination through the in-memory conflict pass.
TestSweepDoesNotTraverseOtherTenants pins the isolation: a 0-permission beta directory does not break an acme sweep.
MCP org_sweep passes ["sweep", "-json"] without -tenant — correct by design. newScope (main.go:143) reads ORG_TENANT with envOr, and the MCP server inherits its environment. This is the established invariant named in the CLAUDE.md ("State and tenant come from the server's own environment, inherited by every child org process").
P1 — DistillRate caps at 50% with Stop hook: STILL OPEN
This finding from the ultrareview (corroborated by codex) is not addressed in either #265 or #266. stop-mark.sh unconditionally appends a mark on every session Stop. survey.count() treats KindCheckpoint and KindMark as mutually exclusive arms in the switch, so a session that distills (checkpoint) AND has the Stop hook fire (mark) contributes 1 to Checkpoints and 1 to Marks. DistillRate = Checkpoints / (Checkpoints + Marks) = 50%, not 100%.
Under the dogfood setup the PR describes, the headline metric is provably ceiling-capped at 50% as soon as the hook is installed. The 100% sample in the PR description is correct only because the hook was absent for that role. Operators running the full dogfood stack cannot reach 100% without reconciling mark and checkpoint semantics.
The fix requires a design call: pair mark+checkpoint by session identity so they fold to one distilled end, or treat a checkpoint as resolving the preceding pending mark. TestDistillCountsSessionEnds (if added) would pin whichever semantics is chosen.
This is the one remaining P1 at this head. The two P1s from the ultrareview posting that are addressed here were also the two flagged by codex and copilot inline comments; none of the bot-raised items remain open except the DistillRate one.
#266 squash additions — clean
AssignConflicts (survey.go:144-169): map-of-sets deduplicated by (work, role), so a duplicate role row does not double-count ownership. Tenant scoping (role.Tenant != tenant) gates the inner loop. Sorted output (by Work, then by Role within each conflict) is deterministic. Zero-conflict case uses make([]AssignConflict, 0) — non-nil, JSON serializes as [].
heldWorks (survey.go:122-128): extracts Work URIs from state.Held (the last valid folded []org.Assignment). For broken chains, withState is called with the last-good state, so Held carries what was held up to the break point — the correct conservative intersection for conflict detection.
render.Sweep conflict output (render/sweep.go): three return paths (conflicts-only, conflicts+other-attention, no-conflicts) are all correct. The assign_conflicts section renders before the aggregate rates and the attention line, which is the right reading order.
TestAssignConflictsAreTenantScopedAndDeterministic and TestAssignConflictUsesHeldNotPhaseOrDangling pin the important correctness properties: cross-tenant isolation, duplicate-row dedup, that a chartered-but-unassigned role with an open assignment is still an owner, and that a dangling claim is not mistaken for ownership.
TestSweepDetectsCrossRoleAssignmentConflicts exercises the full CLI path including unassign reconciliation and text/JSON format.
Summary
The merge is clean. Both ultrareview P1s (readChain prefix loss, tenant isolation) are correctly addressed with tests. The #266 squash adds coherent conflict detection with good tenant scoping and test coverage. The one remaining P1 — DistillRate semantic ceiling — is unaddressed and should go to the judge with a written why per the two-round cap, or be fixed before dogfood data accumulates under the wrong semantics.
Summary
Stacked on #263. The org substrate is a bet — that a session leaves something the next one can inherit — and nothing measured it.
org sweepis the instrument, counted from the chains alone with no cooperation from any agent.The two numbers
Why it replays instead of scanning
An orphan is not a kind. A takeover's own record does not name the claim it stranded — only the fold knows, as a
Danglingtransition.survey.Offolds each record throughorg.Advanceand counts state changes, so aTestOrphanIsCountedFromTheFoldchain (attach → claim → takeover) reportsorphaned=1, where a scan over kinds would report zero.Deliberate renderer choices
—, never0%. "No session has ended yet" and "every session ended undistilled" are exactly the two findings that must not share a value;TestNoDataIsNotZeropins it.BROKENrow carrying the counts accumulated before the break. A sweep that refuses to report anything about a broken chain is the sweep nobody runs twice.Validation
gofmt/go vet/golangci-lintclean;go test ./cmd/org/... ./cmd/org-mcp/...green. New suite builds chains through the real home rather than hand-forging records: orphan-via-fold, distill ratio, no-data-is-not-zero, claims/terminals, broken-chain-is-a-finding, lateness from the declared deadline.🤖 Generated with Claude Code