Skip to content

ORB-164: register git-guardrails on PowerShell and carry the external-interface and never-admin-merge rules in AGENTS.md - #442

Merged
thomasluizon merged 4 commits into
mainfrom
feature/orb-164-harness-repair-api-half
Jul 31, 2026
Merged

thomasluizon merged 4 commits into
mainfrom
feature/orb-164-harness-repair-api-half

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Harness-Lockstep-Peer: 665

ORB-164, the orbit-api half of the harness repair specification carried by ORB-163. This pull request lands only the three items that do not depend on the paired orbit-ui-mobile pull request. The remaining items stay for a later pass, because each one is bound to an edit that has not landed in #665 yet: deleting .github/workflows/claude-review.yml and the four twins that name it, the pr-review/rubric.md twin edit, the byte-exact second-opinion.mjs correction, and the pr-review/SKILL.md clause 6 and 7 corrections.

What changed

1. git-guardrails.mjs now runs on the PowerShell tool as well as Bash. .claude/settings.json registered its only PreToolUse hook on the matcher "Bash", so the identical git command issued through the PowerShell tool fired no hook at all and this repository's local branch protection was silently absent there. There are now two matchers, Bash and PowerShell, both invoking the same hook.

2. AGENTS.md carries the "Never assume an external interface" rule at line 48, with its review-rule counterpart as Code Review Rule 5 at line 97. orbit-ui-mobile PR #659 landed this rule in its own AGENTS.md, but codex workers launched into an orbit-api worktree read THIS repository's AGENTS.md, so the rule did not reach them. Adapted to this repository's externals (dotnet, NuGet) and its existing review-rule numbering rather than pasted.

3. AGENTS.md carries the never-admin-merge prohibition from specification section J3a, at line 33, alongside the existing branch-protection guardrail. It names all three shapes: gh pr merge --admin, a direct PUT /repos/{owner}/{repo}/pulls/{number}/merge, and the GraphQL mergePullRequest mutation. Naming both raw API calls is load bearing, because forbidding only the CLI flag leaves both API paths open. The one legitimate exception is stated: if a merge genuinely needs the admin override, the agent stops and asks Thomas to merge it himself.

The branch was also renamed from thomasluizon/orb-164-harness-repair-api-half to feature/orb-164-harness-repair-api-half, which is what CLAUDE.md requires.

Proof that the guard actually refuses

Observed live in this worktree against the final file state, through a headless session rooted at the worktree so this repository's .claude/settings.json is the project settings in effect. Both attempts were refused before the command reached git.

Through the PowerShell tool:

PreToolUse:PowerShell hook error: [node "$CLAUDE_PROJECT_DIR/.claude/hooks/git-guardrails.mjs"]: BLOCKED git command (Orbit git workflow):
  git push origin main

Direct or force push to main is forbidden (branch protection, squash-merge only). Open a PR.
The user has prevented this. See the "Git workflow" section of CLAUDE.md.

Through the Bash tool:

PreToolUse:Bash hook error: [node "$CLAUDE_PROJECT_DIR/.claude/hooks/git-guardrails.mjs"]: BLOCKED git command (Orbit git workflow):
  git push origin main

Direct or force push to main is forbidden (branch protection, squash-merge only). Open a PR.
The user has prevented this. See the "Git workflow" section of CLAUDE.md.

git-guardrails.mjs:24 reads input.tool_input.command. The refusal echoing git push origin main back verbatim is the confirmation that the PowerShell tool populates that exact field, so the hook needed no payload change. The hook exits 0 silently when command is not a string, so a missing field would have produced a silent pass rather than this block. Local main and origin/main were both at 319c774c when the attempts ran, so a hook miss would have been a no-op push rather than a real one.

Branch protection payload, recorded before the first edit

Specification section J2 requires this because no revert restores branch protection state. Read from GET /repos/thomasluizon/orbit-api/branches/main/protection on 2026-07-30, strict: true, 15 required contexts:

Unit Tests
Build
Dependency Review
OpenAPI Breaking-Change Gate
Dependency Scan
Guard Conventions
Guard Migrations
GitGuardian Security Checks
Analyze (csharp)
Analyze (javascript-typescript)
Analyze (actions)
SonarCloud Code Analysis
Dash Ban
Skill and Agent Frontmatter
Harness Lockstep

This pull request changes no branch protection setting and moves no guards.yml job name.

Harness Lockstep on this pull request, and why

The three files changed here are AGENTS.md, .claude/settings.json and .claude/hooks/git-guardrails.mjs. None of them is one of the six lockstep twins that orbit-ui-mobile/tools/check-lockstep.mjs:16-24 fixes at exactly six entries, and none of them is guards.yml. The Detect a lockstep change step at guards.yml:77-87 therefore evaluates to changed=false, every following step is skipped, and the job reports success without resolving the peer at all. So Harness Lockstep here is green because the twins were untouched, not because the pair is complete. It is not evidence that the twin edits are done, and the later pass that lands them will be the run that actually exercises the sibling checkout.

The Harness-Lockstep-Peer: 665 directive is on the first line regardless, because orbit-ui-mobile#665's mirror job requires the exact reciprocal line naming this pull request's number in this body. #665 must carry Harness-Lockstep-Peer: with this pull request's number or its own Harness Lockstep job fails.

Also fixed while in the file

git-guardrails.mjs carried two em dashes, one of them inside the message the hook prints to the user. tools/check-dashes.mjs skips .claude/ entirely, so the repository wide dash ban never saw them. Both are gone.

Testing

No product code and no new decision path in a tool, so no unit test. node tools/check-dashes.mjs --files AGENTS.md .claude/settings.json .claude/hooks/git-guardrails.mjs exits 0, node tools/check-dashes.mjs --check-baseline exits 0, node tools/check-frontmatter.mjs reports 8 files parsing, node --check .claude/hooks/git-guardrails.mjs exits 0, and .claude/settings.json parses as JSON. The lefthook dashes pre-commit step and the protect-main pre-push step both ran green.

Linear: ORB-164

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH

thomasluizon and others added 2 commits July 30, 2026 19:44
The only PreToolUse matcher named "Bash", so the identical git command issued
through the PowerShell tool fired no hook at all and git-guardrails.mjs, the
local branch protection this repository relies on, was silently absent there.

Verified live in this worktree, both refused after the change:
  PreToolUse:PowerShell hook error: BLOCKED git command (Orbit git workflow):
    git push origin main
  PreToolUse:Bash hook error: BLOCKED git command (Orbit git workflow):
    git push origin main

The refusal echoing the command back confirms the PowerShell tool populates
tool_input.command, the field the hook reads, so no payload change was needed.

Also removes the two em dashes the hook carried; tools/check-dashes.mjs skips
.claude/ so the ban was never enforced on them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
…AGENTS.md

Codex workers launched into an orbit-api worktree read this repository's
AGENTS.md, so orbit-ui-mobile PR #659's "Never assume an external interface"
rule and its review-rule counterpart never reached them. Added here, adapted
to this repository's externals (dotnet, NuGet) and its review-rule numbering.

Adds the never-admin-merge prohibition from the harness repair specification
section J3a alongside the existing branch-protection guardrail: no
gh pr merge --admin, no direct PUT on the pulls merge endpoint, and no GraphQL
mergePullRequest mutation. Naming both raw API calls is the point; forbidding
only the CLI flag leaves the API paths open. If a merge genuinely needs the
admin override, the agent stops and asks Thomas to merge it himself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0175eae63

ℹ️ 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".

Comment thread AGENTS.md
Comment on lines +33 to +35
- Never perform an admin merge, in any shape: no `gh pr merge --admin`, no direct
`PUT /repos/{owner}/{repo}/pulls/{number}/merge`, and no GraphQL `mergePullRequest`
mutation. Naming the two raw API calls is deliberate; forbidding only the CLI flag

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce the new admin-merge prohibition

When a worker invokes gh pr merge --admin or either raw API form, this prohibition remains advisory: the repository-wide search finds no enforcement, and git-guardrails.mjs immediately allows commands without the word git (a direct hook invocation with the gh command exits 0). Because an admin merge bypasses the protections that could reject it after execution, add a pre-execution gate covering all three listed forms rather than placing this rule among guardrails described as mechanically enforced.

AGENTS.md reference: AGENTS.md:L26-L29

Useful? React with 👍 / 👎.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #442 (thomasluizon/orbit-api)

Scope: PR #442 — AGENTS.md, .claude/settings.json, .claude/hooks/git-guardrails.mjs (ORB-164)
Recommendation: APPROVE

Summary

Documentation/tooling-only PR. It registers the existing git-guardrails.mjs PreToolUse hook on the PowerShell matcher (previously Bash-only, so the identical git command issued through PowerShell fired no guard at all), and adds two AGENTS.md sections: a never-admin-merge prohibition and a "never assume an external interface" verification rule adapted from the sibling orbit-ui-mobile repo. No src/, DTO, controller, or packages/shared surface is touched, so backend-hard-rules, security-reviewer, and contract-aligner gates never fire. The PR body is unusually well evidenced (live hook-trip transcripts for both Bash and PowerShell, check-dashes/check-frontmatter/node --check output, a recorded branch-protection payload). No Critical/High findings survived review.

Findings

Critical / High / Medium

None.

Low / Info

  • .claude/hooks/git-guardrails.mjs also removes the file's two pre-existing em dashes (file:74, a user-facing message; file:79, a comment). tools/check-dashes.mjs explicitly skips the whole .claude/ tree, so this was never CI-required — a voluntary fix in exactly the domain (git-workflow enforcement) that upholds the rule. No action needed.
  • The new "never assume an external interface" rule is dogfooded by this PR itself: the PowerShell-matcher claim is backed by a live transcript in the PR body showing the hook actually tripping through the PowerShell tool, not an assumption from --help/memory.

Subagents

Agent Verdict
security-reviewer N/A — gate did not fire (no src/ code changed)
contract-aligner N/A — gate did not fire (no DTO/route/packages/shared touched)

Validation

Check Result
Build (dotnet) N/A — skipped per CI adaptation; no .cs changes in this diff anyway
Tests (dotnet) N/A — same; PR body's own local proof (check-dashes, check-frontmatter, node --check, JSON parse, lefthook dashes/protect-main) is consistent with the diff

Deferred — N/A dimensions & files not verdicted

  • Dimensions 8, 9, 10 (DESIGN.md/AI-slop, web↔mobile parity, i18n) — N/A, frontend-only / no apps/* touched.
  • Dimension 11 (Contract drift + backward-compat) — N/A, no DTO/route/shared-type change; no candidates.
  • Dimension 13 (Backend hard rules) — N/A, no src/Orbit.* touched.
  • Dimension 14 (FEATURES.md parity) — N/A, no user-facing feature surface changed.
  • PR body claim that orbit-ui-mobile PR #659 landed the sibling rule — not independently verifiable, mobile repo not checked out in this job.
  • Whether main's branch protection has enforce_admins enabled — the new "never admin-merge" bullet sits under AGENTS.md's pre-existing framing that the guardrail list is "enforced by ... GitHub branch protection," but an admin-merge override bypasses branch protection by definition unless enforce_admins is specifically on. Not confirmable from this diff alone. Not raised as a ranked finding (no evidence either way), but worth a human check: if enforce_admins is false, either enable it or soften the bullet's implied technical backstop to "policy-only."

What's good

  • Fixes a real, previously-silent guard gap (PowerShell-issued git commands bypassed branch protection entirely).
  • Rigorous self-verification in the PR body, matching the standard its own new rule demands of future changes.
  • New AGENTS.md content is adapted to this repo's externals, not pasted verbatim, and correctly cross-numbered.
  • Correctly scoped: explicitly excludes items still blocked on the unlanded orbit-ui-mobile peer PR rather than landing a partial lockstep pair.

Recommendation

Approve. Optional non-blocking follow-up: confirm enforce_admins: true on main's branch protection so the new never-admin-merge bullet has the technical backstop its section's framing implies.

Branch protection on main requires three code scanning contexts: "Analyze
(csharp)", "Analyze (javascript-typescript)" and "Analyze (actions)". They
were produced by CodeQL default setup, which does not run on Dependabot pull
requests (github/codeql-action issue 2858). The three contexts therefore never
reported on a Dependabot head, and every Dependabot pull request sat
permanently BLOCKED waiting on checks that could not arrive.

Default setup is now disabled on the repository, and this workflow takes over.
An advanced-setup workflow is an ordinary workflow, so it triggers on a
Dependabot head like every other job here, and its job-level permissions block
raises the read-only Dependabot GITHUB_TOKEN to security-events: write.

The matrix job name "Analyze (${{ matrix.language }})" interpolates to exactly
the three required context names, so branch protection needs no change. C# uses
build-mode manual with the same dotnet restore plus dotnet build --no-restore
that API Tests already proves, because this repository generates code at build
time; javascript-typescript and actions use build-mode none. github/codeql-action
is pinned by SHA to tag v4.37.4, matching the repository convention for
non-actions/* actions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #442

Scope: Diff since the last review (commit 988e04c1), which added .github/workflows/codeql.yml. Earlier commits (.claude/hooks/git-guardrails.mjs, .claude/settings.json, AGENTS.md) were already reviewed and approved in a prior review at commit c0175eae and are not re-flagged here.

Recommendation: APPROVE

Summary

This commit adds an 87-line CodeQL "advanced setup" workflow replacing GitHub's default CodeQL setup. The motivation — default setup doesn't run on Dependabot-authored PRs, permanently blocking the three required status-check contexts — is a real, known codeql-action limitation. The workflow reproduces the three required check names (Analyze (csharp), Analyze (javascript-typescript), Analyze (actions)) via matrix-job-name interpolation, uses least-privilege job-level permissions (security-events: write, actions: read, contents: read), SHA-pins github/codeql-action, and reuses this repo's proven dotnet restore && dotnet build --no-restore build steps from test.yml for the C# manual-build-mode leg. No src/ or product code is touched, so backend hard-rules, security-reviewer, and contract-aligner gates don't apply.

Findings

Critical / High

None.

Medium

None.

Low / Info

  • The commit message asserts CodeQL default setup is now disabled on the repository — this is an external GitHub repo-setting change not visible in the diff, and not independently verifiable from this CI job (the code-scanning/default-setup API check was blocked by sandbox permissions). If that toggle wasn't actually flipped, advanced-setup SARIF uploads would be rejected while default setup is still on. Not blocking — worth a quick human glance at Settings → Code security.
  • actions/checkout@v7 / actions/setup-dotnet@v6 are tag-pinned rather than SHA-pinned in the new workflow, consistent with every other workflow in this repo. github/codeql-action is correctly SHA-pinned, matching the repo's convention for third-party actions.
  • No stale references to CodeQL default setup remain elsewhere in the repo that would need updating.

What's good

  • Correctly diagnoses and fixes a real, previously-blocking CI gap (Dependabot PRs stuck on default-setup contexts that never report).
  • Job-name interpolation is explicitly called out as load-bearing for branch protection.
  • Reuses this repo's own proven C# build steps rather than inventing new ones.
  • Permissions scoped tightly at workflow and job level; SHA-pinning matches repo convention.

Validation

Build/Test: N/A — skipped per CI adaptation (separate required checks handle this); no .cs changes in this diff.

Deferred / not verifiable in CI

  • Dimensions covering frontend/mobile parity, contract drift, and FEATURES.md: N/A — no product code, DTOs, or shared types touched.
  • CodeQL default-setup-disabled claim: not independently verifiable from this job's gh api access; flagged as Info above.

"SonarCloud Code Analysis" is a required context on main, and the SonarQubeCloud
app only posts it once an analysis is uploaded. The scanner job was skipped on
Dependabot heads because SONAR_TOKEN did not exist in the repository's Dependabot
secret store, so the required context never reported and every Dependabot pull
request stayed BLOCKED. SONAR_TOKEN is now present in that store, so the guard
has nothing left to protect and is removed. The job already reads the secret as
secrets.SONAR_TOKEN, which is the reference a Dependabot-triggered run resolves
against the Dependabot store.

claude-review.yml identified Dependabot with github.actor, which is whoever
triggered the run, not the pull request author. A human re-run of a Dependabot
run would therefore let the Claude review step through with an empty
CLAUDE_CODE_OAUTH_TOKEN, since that secret lives only in the Actions store. It
now checks the author, matching dependabot-auto-merge.yml. That step stays
skipped for Dependabot on purpose; "review" is not a required context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bojq5cLFZ87iBD8zhnoSWH
@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 3e20b92 into main Jul 31, 2026
23 checks passed
@thomasluizon
thomasluizon deleted the feature/orb-164-harness-repair-api-half branch July 31, 2026 00:37
thomasluizon added a commit that referenced this pull request Sep 8, 2026
thomasluizon added a commit that referenced this pull request Sep 8, 2026
thomasluizon added a commit that referenced this pull request Sep 9, 2026
* Prepare atomic streak gap repair (#442)

* Add atomic repair for contiguous streak gaps (#442)

* Preserve freeze award history across streak gap repair (#442)

* Protect legacy streak award cursor (#442)

* Refresh architecture map (#442)

* fix: make gap repair follow the schedule, not the calendar

Streak continuity runs over SCHEDULED occurrences, never calendar days: a weekly
habit's streak survives the six unscheduled days between two occurrences. Three
places asserted calendar adjacency instead, so a weekly or every-N-day user could
never repair a gap at all. The day before the gap is not a scheduled occurrence,
so it carries neither a completion nor a freeze, and the check always refused.

The rule moves to where the schedule is actually known:

  - UserStreakService walks the ordered scheduled occurrences. The selection must
    be an unbroken run of them, so a caller still cannot omit a missed occurrence
    inside the gap, and the PRECEDING SCHEDULED occurrence must be completed or
    frozen.
  - StreakFreeze.CreateGap drops calendar-consecutiveness. That entity cannot see
    a schedule, so it was rejecting valid gaps before one was ever loaded. It
    keeps what a date list alone can prove: real dates, no duplicates, and a last
    date of local yesterday.
  - RepairStreakGapCommandValidator likewise checks duplicates only.

Red first, on the half that carries the defect. Reverting just the preceding
occurrence lookup to `gapStart.AddDays(-1)` fails the new weekly case; restored,
the suite is 23/23.

The second new case keeps the widening honest: when the prior week's occurrence
was ALSO missed, one selected date is not a whole gap and repair stays refused.
Schedule-awareness widens which gaps are contiguous; it never waives the
precondition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: carry the scheduled predecessor, and reach it at the yearly boundary

Three defects from the same root as the previous commit: continuity is defined
over scheduled occurrences, and three more places still assumed calendar days.

1. THE AWARD CURSOR. The handler passed `gap[0].UsedOnDate.AddDays(-1)` to
   RestoreStreakAfterGapRepair while the service had already computed the real
   scheduled predecessor. On a sparse cadence those differ, the cursor match in
   User.RestoreStreakAfterGapRepair failed, and the derived-cursor fallback marked
   a newly crossed seven-day milestone as awarded WITHOUT granting its freeze,
   permanently suppressing the reward. The predecessor now travels on
   UserStreakState instead of being re-derived a layer up, the same one-source
   shape the readiness fallback needed.

2. THE YEARLY BOUNDARY. A yearly occurrence on yesterday has its predecessor 366
   days before the gap, but history loaded only MaxStreakLookbackDays = 365. The
   predecessor fell outside the window, so the gap was index 0 and every yearly
   and every-365-day gap was refused. The window widens by MaxScheduleSpanDays,
   the longest span between two consecutive occurrences of a supported cadence,
   so it stays bounded. The GAP itself is still confined to the ordinary streak
   window; only its predecessor may sit in the widened margin.

3. Three tests pinned the calendar rule the previous commit removed. Each is
   updated rather than deleted, because the behaviour they guard survives: a
   calendar-sparse selection is still refused and still spends no freeze. It is
   now refused by the schedule-aware evaluation rather than the domain factory,
   which is exactly the relocation that makes a real weekly or yearly gap
   repairable.

Full orbit-api suite: 6150 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: union both schedule windows instead of widening one past its cap

The previous commit widened the schedule request to reach a yearly predecessor
and broke ordinary habits doing it. GetStreakScheduledDates caps a generated
range at MaxRangeDays, so one 731-day request truncated the RECENT end: for a
habit created before the widened start, the gap ending yesterday fell outside the
generated dates and a valid repair reported unavailable. My yearly fixture hid it
because that habit starts AT its predecessor, so effectiveFrom moved forward and
the truncation never fired.

Two bounded queries now, unioned. Each is inside the cap, so nothing is lost at
either end.

The yearly case then failed for a different reason, found by probing the helper
rather than by reasoning about it: both queries returned exactly the right dates,
but ComputeStreakAsOf still walked from the recent bound, so the predecessor that
MAKES the repair worth anything sat outside the walk. The repaired streak scored
no gain over the current one and the repair was refused as useless. The walk now
starts at the earlier bound, which is where the schedule now starts.

With the union in hand the predecessor is simply scheduled[gapStartIndex - 1]
again, so the second lookup branch is gone.

Regression added, per the required outcome: a daily habit created 800 days ago,
older than the widened start, still repairs a gap ending yesterday.

Full orbit-api suite: 6151 passed, 0 failed. One unrelated accountability test
flaked once mid-session and passes 3/3 in isolation and in the two runs either
side of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: decide eligibility over the window the streak engine actually uses

Reverts the widened history window. Widening only the repair path made
eligibility and representation disagree, and the review caught the consequence:

  - EvaluateGapRepairAsync accepts the repair on widened history and spends a
    freeze;
  - the handler builds its response through GetStreakInfoQuery, which calls
    CalculateStateAsync on the ORDINARY 365-day window;
  - that window sees yesterday's freeze without the preceding completion, so it
    computes a zero streak;
  - a later RecalculateAsync persists that zero, after the freeze was spent.

The user pays a freeze and gets nothing. Refusing a repair is strictly better
than accepting one that cannot survive the next recalculation.

So: ONE window, the engine's own, and the invariant written down. Eligibility is
decided over exactly the history that can represent the result. Weekly and
every-N-day gaps, which is what this ticket asked for, work entirely inside it.

Yearly gaps stay refused, exactly as they were before this PR, so nothing
regressed. The test now pins WHY rather than asserting they work, and the fix
belongs to the engine's window: filed as thomasluizon/orbit-tickets#483 with the
two traps recorded, including that GetStreakScheduledDates caps a per-habit range
at MaxRangeDays so any future widening must query in bounded slices.

MaxScheduleSpanDays goes with the approach that needed it.

Full orbit-api suite: 6151 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: state the CreateGap rationale as XML-doc, not narration

The Roslyn ORBIT0001 gate caught my own violation: a five-line `//` block
narrating why calendar-consecutiveness is absent. Only XML-doc or a WHY note
linking an upstream URL is allowed.

The rationale is worth keeping, so it becomes a summary plus remarks on the
method rather than being deleted.

Verified the way CI builds it: `dotnet build --no-incremental` is clean with zero
ORBIT diagnostics. The earlier local `dotnet test` runs never recompiled this file
against the analyzer, which is why CI saw it first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: keep the first break's cursor, and bound the legacy fallback

Two award-cursor defects, both of which silently cost the user a freeze.

1. A LATER DECREASE OVERWROTE THE SNAPSHOT. SetStreakState captured the pre-gap
   cursor on EVERY decrease, so log, unlog and relog of today after missing
   yesterday replaced (7, Sep 4), which identifies the repairable gap, with
   (0, Sep 6), which describes the break itself. Repair's exact match then failed
   and the derived cursor skipped a milestone that was never granted. Only the
   FIRST decrease captures now, and the pair is captured together so the date can
   never belong to a different run than the streak beside it.

2. THE LEGACY FALLBACK ROUNDED THE WRONG STREAK. With no matching snapshot, which
   is every pre-migration row, the cursor came from the FULL repaired streak, so
   milestones crossed by completions AFTER the gap were recorded as already
   awarded and their freezes were never issued. A row with six completions before
   the gap and one after reached 7, spent its banked freeze, and could never
   receive the one it had just earned.

   The fallback is now bounded by the streak going INTO the gap, which the service
   computes as of the scheduled predecessor and carries on UserStreakState. The
   cursor cannot advance past what was earned before the gap, so a newly crossed
   milestone stays awardable.

`RestoreStreakAfterGapRepair` takes preGapStreak as a required parameter rather
than an optional one: every caller should have to say what was earned before the
gap, and there is exactly one production caller.

Full orbit-api suite: 6153 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* test: prove the cursor fixes where they can actually fail

The review asked for these regressions to run through UserStreakService and the
handler rather than mutating User directly. One of the two cannot honestly be
written that way, and the first attempt at it was a test that could not fail.

The legacy-fallback case IS now handler-level: six completions before the gap, one
after, a pre-migration row with no snapshot and a banked freeze, driven through
RepairStreakGapCommandHandler. It proves the milestone crossed after the gap stays
awardable.

The capture-once case stays on the entity, and the test says why. Driving it
through RecalculateAsync needs an increase followed by a decrease, and this fixture
cannot produce one: measured, Unlog leaves the recalculated streak at 1 rather than
returning it to 0, so the service never sees a second decrease. The service-level
version therefore passed with the fix REVERTED and proved nothing, which is worse
than no test because its name claimed coverage it did not have.

Red first, both, against the same revert:

  ASecondDecrease_KeepsTheSnapshotFromTheFirstBreak          FAIL
  LegacyRowCrossingAMilestoneAfterTheGap_StillEarnsItsFreeze FAIL
  restored: 28 passed

BuildRepairHandler is extracted now that a third caller exists, and the existing
milestone theory uses it instead of building the same eight substitutes inline.

Full orbit-api suite: 6153 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: grant the milestone repair earns, and cap a stale saved cursor

Two more cursor defects, both mine.

1. REPAIR MADE A MILESTONE ELIGIBLE AND NOTHING GRANTED IT. Restoring the cursor
   only clears the way; the response is built by GetStreakInfoQuery, which calls
   CalculateAsync rather than RecalculateAsync, so an award earned by the repaired
   run sat pending and was lost the next time the streak reset. The repair now
   grants it in the same save that spends the bank. My earlier test hid this by
   calling AwardStreakFreezeIfEligible by hand, which production never does; it
   now asserts the freeze is actually banked.

2. THE SAVED CURSOR OUTRANKED THE RUN IT DESCRIBED. The snapshot deliberately
   survives later recalculations, so it can describe a LONGER run than the one
   being repaired: unlog an older pre-gap completion and a cursor of 14 still
   matches on date while the run is now 7, suppressing awards the shorter run has
   re-earned. The saved value is capped by what the current pre-gap run supports.

And a hazard in my own design, caught by two existing tests going red: PreGapStreak
defaulted to 0, so any caller that did not supply it read as "the run into the gap
was zero", collapsing the cursor and re-granting milestones the user already had.
It is nullable now. Unknown means DO NOT BOUND, which is the behaviour that stood
before the bound existed, rather than silently meaning zero.

Full orbit-api suite: 6154 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix: move the cursor rationale into XML doc for ORBIT0001

CI rejected an eight-line narration block in User.cs. The rationale is worth
keeping, so it moves onto the preGapStreak parameter documentation, where it also
reaches anyone reading the signature rather than only someone inside the method.

A correction worth recording, because I got this wrong twice tonight: the ORBIT
Roslyn analyzers DO NOT run in the local `dotnet build`. A clean local build,
even `--no-incremental`, proves nothing about ORBIT0001. Only CI catches it, and
only for the Orbit.Domain project, which is why identical `//` comments in
Orbit.Application and Orbit.Infrastructure pass. Check Domain comments by eye or
push and read CI; do not trust a green local build for this rule.

Full orbit-api suite: 6154 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B

* fix(gamification): use XML block comments so ORBIT0001 accepts them

CI failed the Build on ten ORBIT0001 diagnostics in `Orbit.Application`, not in
`Orbit.Domain`: seven in `RepairStreakGapCommand` and three in
`RepairStreakGapCommandValidator`. The analyzer covers every project except
migrations and generated files, so the project is not what made the difference.

A clean local build proves nothing here, and now there is a measurement for why.
`dotnet build` on this tree emits `warning CS9057`: the analyzer assembly
references compiler version 5.9.0.0 while the local SDK runs 5.6.0.0, so Roslyn
DISABLES the analyzer and the build reports 0 errors on the exact file CI then
rejects. Locally the ORBIT rules do not run at all, in any project.

`NoCommentsAnalyzer.IsAllowed` accepts a multi-line comment that starts with
`/**`, so the three narration blocks keep every word and change only their form.
Nothing was deleted: each one records a defect this pull request fixed, and a
sweep of the whole diff for added `//` lines outside `///` now returns nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ

* fix(streaks): finish the ORBIT0001 sweep across every analyzed project

The previous commit fixed `Orbit.Application` and CI then failed on nineteen
more diagnostics in `Orbit.Infrastructure`. The build stops at the first failing
project, so fixing one only reveals the next, and my first sweep was truncated
by a `head -30` and reported clean when it was not.

This is the whole sweep, run to completion. `Directory.Build.props` attaches the
analyzer to EVERY project except the analyzers themselves, tests included, so
the pass covers `src/` and `tests/` together: five blocks in `UserStreakService`
and four in `StreakGapRepairTests`. Grepping the complete repository for an
own-line `//` outside `///`, a URL note, a migration and generated output now
returns nothing.

Every word is kept. `NoCommentsAnalyzer.IsAllowed` accepts a multi-line comment
that starts `/**`, so only the form changed, and the converter refuses a block
containing `*/` rather than silently ending the comment early.

Local evidence and its limit: `dotnet build` reports 0 errors on this tree and
proves nothing about ORBIT0001. Every project emits `warning CS9057`, because
the analyzer assembly references compiler 5.9.0.0 while the local SDK runs
5.6.0.0, so Roslyn disables it. The rules do not run locally at all, in any
project. Only CI can confirm this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ

* fix(streaks): repair inside the habit lock, so eligibility cannot go stale

Pullfrog raised this three times and was right every time. I resolved the first
two claiming it was pre-existing and architectural. That premise is false, and
two commands disprove it:

  git log --oneline origin/main -S "EvaluateGapRepairAsync" -- src/   no output
  git cat-file -e origin/main:.../RepairStreakGapCommand.cs           absent

Both the evaluation and the handler are introduced by THIS pull request, so the
read-then-write hazard is created here and cannot be deferred to a ticket.

The defect: EvaluateGapRepairAsync decides from the habits, the completion logs,
the freezes and the schedule derived from them, and the save then spends the
freeze bank. Those were two separate snapshots. `User.xmin` cannot hold them
together, because a schedule-only habit edit commits WITHOUT touching the user
row, so the optimistic token this command already retries on never fires. A
cadence or due-date change landing between the two left a freeze spent on a date
the committed schedule no longer accepts, with the bank charged all the same.

The boundary already existed and this handler simply never joined it.
HabitCeilingLock takes a transaction-scoped per-user advisory lock, and
UpdateHabitCommand, LogHabitCommand, CreateHabitCommand, MoveHabitParentCommand
and RestoreHabitCommand all hold it. The repair now runs inside
HabitCeilingLock.ExecuteAsync, so evaluation and the spend happen in one
transaction under the same key every writer whose output it reads must take. The
user is loaded inside the lock too: a load taken before it would carry a pre-lock
snapshot of the bank into a post-lock decision.

The regression asserts ORDER rather than presence, because presence proves
nothing about the window: lock, then evaluate, then save, with the key read from
HabitCeilingLock.ForUser itself so a rename cannot leave it passing against a
lock nobody else holds.

Wiring the transaction exposed six failing end-to-end cases in
StreakGapRepairTests: a substituted IUnitOfWork returns null from the transaction
wrapper unless the operation is invoked. Both sites now run it as the real one
does, which is also what makes those cases exercise the real boundary.

Ticket 484 stays open, scope corrected, for what this does not cover: writing the
invariant down, sweeping other handlers that spend a bank from an unprotected
Habit or HabitLog read, and deciding whether those tables should carry a
concurrency token so the hazard is detectable rather than only preventable.

Test evidence: dotnet test, 6155 passed, 0 failed
  Orbit.Analyzers.Tests        32 passed
  Orbit.Domain.Tests          582 passed
  Orbit.Application.Tests    3313 passed
  Orbit.Infrastructure.Tests 2228 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ

* fix(gamification): extend the habit consistency boundary to every writer (#484)

Pullfrog was right that `HabitCeilingLock` did not deliver the boundary this pull request
claimed. Counted live on `main`: `DeleteHabitCommand`, `SkipHabitCommand`,
`BulkLogHabitsCommand` and `BulkSkipHabitsCommand` had ZERO references to it, and
`LogHabitCommand` had one, on the unlog branch only. So a soft delete could commit between
the repair's eligibility read and its spend, leaving a banked freeze paid for a schedule
that no longer existed.

Every persisted mutation of habit schedule, completion logs or freeze currency now commits
inside the lock:

- `DeleteHabitCommand`, `BulkDeleteHabitsCommand`
- `SkipHabitCommand`, `BulkSkipHabitsCommand`
- `LogHabitCommand`'s log path, `BulkLogHabitsCommand`
- `RepairStreakCommand`, the other currency spender, which had the same read-then-write
  shape as the gap repair and none of its protection
- the chat tools' single commit point in `ProcessUserChatCommand.Persistence`, which is
  where every Astra habit mutation actually lands
- the offline-sync habit delete replay in `SyncController`

A writer only holds the lock across the save. The lock is transaction scoped, so what it
serializes is the interval from acquisition to commit; derived work that follows, such as
streak recalculation, gamification, challenges and referrals, stays outside it and keeps
writing the xmin-tokened user row.

Two unique-violation catches moved OUT of their transactions. A failed statement aborts a
PostgreSQL transaction block, so the reply query each one runs could not have executed
inside it. `HabitCeilingLock` becomes public for the one caller outside
`Orbit.Application`.

The regression runs `DeleteHabitCommandHandler` concurrently with the repair against a
serializing unit of work and asserts the interleaving, not a call count. Removing the lock
from the delete alone turns it red in 10 seconds. The serializing fake moved out of
`HabitCeilingLockTests` so both suites share one.

Full suite green: 6,156 tests across analyzers, domain, application and infrastructure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53

* fix(gamification): use doc comments so ORBIT0001 accepts the boundary notes

The Roslyn analyzer bans `//` comments and it did not run locally: the build printed
CS9057 and skipped `Orbit.Analyzers.dll`, because that assembly references a newer compiler
than the installed SDK. So the ban only fired in CI. Every note added by the previous commit
is now a `/** */` block, matching the file it sits in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53

* chore(arch): refresh the generated map for the new boundary test

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53

* fix(tests): finish the ORBIT0001 sweep in the concurrency regression

The previous sweep covered src/ and missed the new test file, so the same analyzer that
does not run locally failed the build again on three comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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