Skip to content

feat(sync): opt-in git attribution spans on sync push --attribution - #848

Open
Enclavet wants to merge 3 commits into
getagentseal:mainfrom
Enclavet:feat/sync-yield-attribution
Open

feat(sync): opt-in git attribution spans on sync push --attribution#848
Enclavet wants to merge 3 commits into
getagentseal:mainfrom
Enclavet:feat/sync-yield-attribution

Conversation

@Enclavet

Copy link
Copy Markdown

What

Adds an opt-in --attribution flag to codeburn sync push that sends the session→commit correlation codeburn yield already computes locally, so a telemetry backend can join AI usage to git outcomes (merged / never merged / reverted) — without teams having to install per-developer git hooks.

codeburn sync push --attribution
codeburn sync push --attribution --dry-run   # counts only, sends nothing

Why

codeburn sync today answers how much AI was used (tokens, cost, model, project). It cannot answer where that AI output landed: which commits came out of a session, whether they shipped to main, or whether they were later reverted. Teams that want outcome metrics (AI-to-merge ratio, defect/revert rates on AI-assisted code) currently have to bolt on commit-trailer git hooks — a per-developer install with its own drift and maintenance burden.

But codeburn already has the hard part. codeburn yield resolves each session's project to a canonical repo (monorepo subdirs and worktrees collapse via git-common-dir), attributes commit SHAs to sessions by tightest timestamp window, and computes inMain / wasReverted per commit. That analysis just never left the local CLI report. This PR exposes it through the existing sync channel.

What gets sent (only with the flag)

Two new span types, sharing the session's traceId with the existing usage spans (deriveTraceId(sessionId)), so receivers correlate cost and attribution with no extra key:

codeburn.session.attribution — one per session with joinable evidence:

Attribute Example
ai.session_id abc123…
ai.project my-app
git.repo github.com/acme/widget (normalized origin remote)
git.pr_links ["https://github.com/acme/widget/pull/12"]
git.commit_count 2

The span's start/end times are the session window itself.

codeburn.commit — one per commit attributed to a session:

Attribute Example
git.sha 4f2a…
git.in_main true
git.was_reverted false
git.repo, ai.session_id, ai.project (join keys)

A resource attribute codeburn.attribution_methodology: timestamp-window marks the attribution as inferred (same self-declared heuristic as codeburn yield), so backends can label it honestly versus declared sources like commit trailers.

Design decisions

  • Remote normalization happens client-side. git remote get-url origin is normalized to host/org/repo: scp-like (git@host:org/repo.git), ssh:// (user + port dropped), and https:// (embedded credentials stripped — a token in an https remote must never leave the machine) all collapse to the same key. Local-only repos and file:// remotes have no server-side identity: their commits are never sent. A session in such a repo still emits a record when it carries PR links (the PR URL embeds the repo).
  • State-encoding dedup keys make updates flow through the existing ledger. A commit's key encodes inMain/wasReverted (and the session key hashes repo + PR links + commit states). Identical facts dedupe via the sent-ledger exactly like usage spans; a state transition (commit merges to main, or gets reverted) mints a new key and the updated fact is re-sent on the next push. Receivers should upsert by (git.repo, git.sha).
  • Attribution rides after the usage push, same endpoint, same auth, same 429/partial-success handling. It is skipped when the usage push hit rate limits or server errors (both retry next push). Zero-cost items don't inflate the $ summary; a separate Attribution: N facts synced line is printed.
  • No new privacy defaults. Without --attribution, behavior is byte-identical to today. With it, what leaves the machine is: normalized repo remote, commit SHAs + timestamps, PR URLs, and the merged/reverted booleans. Never code, diffs, paths, prompts, or bash commands. Documented in docs/sync/README.md.

Implementation

  • src/yield.ts: the repo-grouping loop inside computeYield is extracted into buildRepoGroups (verbatim; grouping semantics unchanged) and shared with a new computeAttributionRecords(projects, range, cwd). Also exports normalizeRemoteUrl. computeAttributionRecords takes already-parsed projects, so sync push doesn't re-parse.
  • src/sync/otlp.ts: flattenAttributionRecords, dedup key builders, buildAttributionOtlpPayload, batchAttributionItems.
  • src/sync/push.ts: the send loop is generalized into sendBatchesCore (the public sendBatches signature and behavior are unchanged); adds collectUnsentAttribution + sendAttributionBatches.
  • src/sync/cli.ts: the --attribution flag, dry-run reporting, and summary output.

Testing

  • 16 new tests (tests/sync-attribution.test.ts): remote normalization (ssh/scp/https/credentials/local), record computation against real temp git repos (remote join, tightest-window ownership, no-remote handling, empty-session omission), dedup key state transitions, OTLP span shape, and the send+ledger pipeline against a mock OTLP server (success ledgering, 5xx not ledgered).
  • All existing yield/sync suites pass unchanged (93 tests across yield*.test.ts, sync*.test.ts) — computeYield behavior and the sendBatches contract are untouched.
  • Manual E2E: scratch repo with a real history (merged commit, never-merged branch commit, git reverted commit) pushed through the full pipeline to a live local HTTP server. Verified: correct in_main/was_reverted flags from real git, credential stripped from an https://user:token@… remote with a full-payload scan, idempotent re-collect after ledgering, and correct 2-fact re-send after merging the feature branch (state transition).

Compatibility

  • Opt-in flag; no change to default sync push, payload shape, discovery doc, or auth.
  • Receivers that don't recognize the new span names can ignore them (they're ordinary OTLP spans). Strict receivers that reject unknown spans would surface via the existing partial-success path; such teams simply don't pass the flag.
  • tsc clean; no new dependencies.

Caveats (by design)

  • Attribution is heuristic. Timestamp-window correlation can mis-assign a commit when a human commits unrelated work mid-session in the same repo — the same tradeoff codeburn yield documents. The methodology resource attribute exists so dashboards can label inferred vs. declared attribution.
  • The wasReverted signal only detects standard git revert bodies ("This reverts commit <sha>"), matching yield's existing detection.

Possible follow-ups (not in this PR)

  • A sync.attribution: true config option so scheduled pushes don't need the flag.
  • Fork-workflow refinement: prefer the PR-link repo identity over origin when both exist (origin may point at a fork).

@Enclavet

Copy link
Copy Markdown
Author

Intentionally made this opt-in. As the remote repo name and pr-links might be sensitive.

@iamtoruk iamtoruk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks — the core of this is strong work, and the review went deep because this is the product's most privacy-sensitive surface. What held up very well under adversarial testing: the credential stripping survived 48 hostile remote forms (userinfo tokens, GitLab subgroups, scp-forms with colons in usernames, query-string tokens — zero leaks, no ReDoS), --dry-run is provably silent on the wire (zero /v1/traces POSTs against a live collector), flag-off behavior is byte-identical to main, the yield.ts extraction is behavior-preserving across 4,186 real sessions, and reusing the #660 plumbing (backoff, assertHttps, ledger) was exactly right.

Two egress bugs block the merge — both demonstrated end-to-end against a live mock collector:

1. The cwd fallback egresses unrelated (possibly confidential) repos. buildRepoGroups does identity = projectIdentity ?? cwdIdentity (src/yield.ts:390-392, 574). When a session's project path no longer resolves to a work tree (deleted/renamed dir, or sessions run outside a repo), attribution inherits whatever repo the user's shell is in at push time. Reproduced: pushing from inside a private repo emitted repo: "github.com/secret-org/nda-client-repo" plus its commit SHAs, attributed to a session that never touched that repo. In local yield this fallback is a harmless heuristic; on the sync path it's both a privacy leak and false attribution. Suggested fix: on the attribution path, drop the group (or at minimum repo + commits) whenever identity came from the cwd fallback rather than the project's own path.

2. Windows drive-letter paths defeat the "local repos are never sent" guarantee. The scp-like branch matches C: as a host (src/yield.ts:159-160): "C:/Users/alice/private/repo"repo: "c/Users/alice/private/repo", and because repo is non-null, commits are sent — directly contradicting the doc line "Commits in repos with no network remote are never sent," with the user's local filesystem path as the emitted identity. Suggested fix: reject single-character hosts, or match /^[a-zA-Z]:[\\/]/ before the scp-like branch.

3. Tests for the above. The new test file is genuinely good (live collector, wire assertions), but it covers only 3 basic normalize cases — none of the adversarial forms, no Windows path, no --dry-run no-network assertion, no flag-off assertion. All the findings above would have been caught by that corpus.

Should-fix (non-blocking but please consider in the same pass):

  • git.pr_links from host-emitted journal entries passes only a truthy-string check (src/parser.ts:1265 upstream) — arbitrary strings of arbitrary length reach the endpoint. A shape check (URL, allowlisted hosts) + per-session cap would close it. Relatedly, PR links are sent even when repo is null (deliberate and defensible, but the docs' "never sent" sentence reads more absolute than reality — worth reconciling).
  • Attribution items have no MAX_PER_PUSH-style cap; a first --since all --attribution push on a long history has no valve.
  • A CHANGELOG ## Unreleased entry — a privacy-relevant opt-in flag belongs in release notes.
  • The PR body's attribute table under-declares vs the wire: codeburn.device_id, codeburn.attribution_methodology, and commit timestamps (span start times) also ride along. Your docs/sync/README.md discloses the timestamps honestly — the PR body should match it.

Nice-to-have: case-insensitive .git strip and slash collapse for join-key stability (…/Repo.GIT and doubled slashes currently split one repo into two keys); document that only origin is read.

Happy to re-review promptly — the shape of this feature is right and the plumbing reuse makes it an easy yes once the egress edges are closed.

@Enclavet

Copy link
Copy Markdown
Author

Thanks for the thorough review — the two privacy findings were real, and both are now fixed in ce800d8, along with all the should-fixes. Point by point:

1. cwd-fallback egress — fixed at the source. buildRepoGroups now tracks per-session identity provenance (ownIdentity[], parallel to sessions[]): true only when the session's own project path resolved to the repo. On the attribution path, fallback sessions get no repo and no commits — they only emit a record if they carry PR links, which are session-native and safe. Your repro is now a test (never egresses the cwd repo for sessions whose project path did not resolve), asserting the private repo identity appears only on the genuinely-cwd session's record.

One consequence worth calling out: I went further than dropping repo+commits. Fallback sessions are excluded from the tightest-window competition entirely — otherwise a fallback session with a tighter window could still steal a commit from the genuine session that owns it (the genuine session would then read as abandoned, a false negative your suggested fix wouldn't have caught). That's also tested (fallback sessions cannot steal a commit from a genuine session). codeburn yield behavior is unchanged — the fallback remains a harmless local heuristic there.

2. Windows drive letters — fixed. normalizeRemoteUrl now rejects ^[a-zA-Z]:[\\/] before the scp-like branch, and the scp host class requires ≥2 chars (catches drive-relative C:repo) and excludes @ — while writing the tests I found git@C:/foo could backtrack into matching host git@C, so that hole is closed too. Dotless intranet hosts (gitserver:team/repo.git) still normalize. Covered: forward/backslash drive paths, lowercase drives, drive-relative, single-char hosts.

3. Tests. The corpus you asked for is in:

  • Adversarial normalize forms (both must-fix classes would now fail CI)
  • The cwd-fallback egress repro + commit-stealing prevention (real temp git repos)
  • CLI-level tests (tests/sync-attribution-cli.test.ts) driving the real commander action against the mock IdP, which I extended with a /v1/traces collector: --dry-run --attribution results in zero traces POSTs; push without the flag emits no attribution span names and no git.sha anywhere on the wire; push with the flag emits usage + attribution spans.

Should-fixes — all taken:

  • PR link validation: new sanitizePrLinks — https only, pathname must match /org/repo/pull/N, ≤256 chars, capped at 20/session. I chose path-shape over a host allowlist so GitHub Enterprise hosts keep working; happy to tighten to an allowlist if you'd prefer.
  • Docs reconciled: the absolute "never sent" sentence in docs/sync/README.md is replaced with an explicit sent/not-sent list, including that PR links ride even when the repo is null (with the rationale: the URL names the repo the session already recorded).
  • Cap: MAX_ATTRIBUTION_PER_PUSH = 10_000 mirrors MAX_PER_PUSH; both the real push and --dry-run report when it engages, and the ledger resumes on the next push.
  • CHANGELOG: ## Unreleased### Added entry with the privacy guarantees summarized.
  • PR body: updated to disclose codeburn.device_id, codeburn.attribution_methodology, and commit timestamps (span start times), matching the docs.

Full suite: tsc clean, 118 tests green across the yield/sync/attribution suites (25 new in this PR).

@Enclavet
Enclavet requested a review from iamtoruk July 28, 2026 23:31
@Enclavet

Enclavet commented Aug 1, 2026

Copy link
Copy Markdown
Author

@iamtoruk Can you take a look at this PR when you get a chance?

@iamtoruk iamtoruk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed after ce800d8. The two blockers from the last round are genuinely closed: the cwd-fallback egress gate holds (fallback sessions emit no repo and no commits, and can no longer steal a commit from a genuine session's window), and the Windows drive-letter path is rejected including the git@C:/foo backtrack you called out. The computeYield to buildRepoGroups extraction is behavior-preserving, the sendBatches to sendBatchesCore refactor has no drift, ledger correctness holds (nothing ledgered without server acceptance, 429 retry is idempotent), the scp-like regex is ReDoS-safe, and flag-off behavior is byte-identical to main. All verified by execution against real temp git repos and a mock collector.

But the same credential-egress class the last round flagged is still open on three independent paths, all reproduced end to end against this branch. The headline guarantee ("a token embedded in an https remote must never leave the machine") is falsifiable, so I have to keep this at changes-requested.

Blocking

1. normalizeRemoteUrl leaks credentials via regex backtracking (src/yield.ts:167)

A remote that carries userinfo but is not caught by the :// branch (missing a slash, or no scheme at all) falls into the scp-like branch, where the optional (?:[^@/]+@)? userinfo group backtracks and abandons itself. The host then matches the credential prefix and the rest of the string, token included, is dumped into the path:

x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git
  -> git.repo = "x-access-token/ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo"

https:/user:ghp_TOKEN@github.com/org/repo.git   (one dropped slash)
  -> "https/user:ghp_TOKEN@github.com/org/repo"

oauth2:glpat-TOKEN@gitlab.com/org/repo.git
  -> "oauth2/glpat-TOKEN@gitlab.com/org/repo"

Well-formed https:// remotes still strip correctly (fuzzed 5 schemes x 5 userinfo forms x 4 paths, zero leaks), so the trigger is a malformed or hand-edited origin, not the default. But the invariant is stated as absolute, and this makes it false. Excluding @ from the host class (the ce800d8 fix) does not help, because it only blocks @ inside the host, not the backtrack. Suggested fix: split on the first @ before host matching, then reject any host or path that still contains @.

2. Transport-helper remotes leak local secrets, and git.repo has no shape or length guard (src/yield.ts:167,188)

sanitizePrLinks strictly shape-checks PR URLs, but the repo identity gets no equivalent check, so git remote get-url origin output reaches the wire verbatim:

ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git
  -> git.repo = "ext/:ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private"   (local SSH key path)

codecommit::us-east-1://MyAwsProfile@MyRepo
  -> "codecommit/:us-east-1://MyAwsProfile@MyRepo"   (AWS profile name)

git-remote-ext and git-remote-codecommit are shipped transports, so these are real origin values. A 10,000-char remote also produces a 10,000-char git.repo. Suggested fix: allowlist the repo identity to a sane host/path shape (the same discipline sanitizePrLinks already applies) and bound its length.

3. sanitizePrLinks passes userinfo, query strings, and fragments (src/yield.ts sanitize path)

It validates only url.pathname, so everything else in the URL rides along:

https://alice:ghp_TOKEN@github.com/acme/widget/pull/5   -> kept verbatim (credential)
https://github.com/acme/widget/pull/7?notification_referrer_id=xyz   -> query kept
https://github.com/acme/widget/pull/6#fragment   -> fragment kept

This is the function whose whole job is gating what leaves the machine, and it is the most reachable of the three (PR links are copy-pasted by humans and routinely carry referrer tokens). Suggested fix: reconstruct the URL from origin plus pathname only, dropping username, password, search, and hash.

Should-fix (not blocking)

  • Stale session attribution is never retracted (double-count). When a commit migrates to a later-parsed session with a tighter window, the earlier session's codeburn.session.attribution span is not re-emitted, so a backend summing git.commit_count across session spans permanently double-counts. The documented (git.repo, git.sha) upsert saves the commit span but not the session span. Reachable whenever a second session for the same repo is parsed on a later push (still-being-written transcript, session that grew its window).
  • Session dedup key omits project and both timestamps (src/sync/otlp.ts:188). An ongoing session's span end time and project name are frozen at first send and never corrected, which defeats the stated purpose of the end timestamp.
  • Attribution summary line can claim success on a failed push (src/sync/cli.ts:385). On a rate-limited or server-error attribution push, nothing is printed and it still writes Attribution: N facts synced. The usage path messages both outcomes; please mirror it.
  • Attribution spans can emit endTime of 0 or less than startTime with no clamp (src/sync/otlp.ts:236) when a timestamp is malformed or out of order. The usage builder guarantees end = start + 1ms; the attribution builder should too.
  • Case-sensitive .git strip splits Repo.GIT and doubled slashes into a second join key. Minor data-quality wart, still worth closing for stable joins.

Verification notes

CI is green and the new attribution suites pass. The 2 failing tests in the full local run (parser.test.ts copilot purge and durable-orphans) reproduce identically on base main e7576e7, so they are pre-existing and not from this PR.

The shape of the feature is right and the plumbing reuse is exactly what I wanted. Please close the three egress paths (they share one root: no single strict sanitizer gates the repo identity and PR links the way the remote :// branch does), and I will re-review promptly.

andklee added 3 commits August 2, 2026 12:28
Expose the yield session-to-commit correlation through codeburn sync so
backends can join AI usage to git activity without local git hooks.

- yield: export normalizeRemoteUrl (host/org/repo; credentials, ports,
  and .git stripped) and computeAttributionRecords, which reuses the
  exact repo-grouping + tightest-window attribution from computeYield
  (extracted into a shared buildRepoGroups) and joins in the normalized
  origin remote and session prLinks.
- otlp: two new span types sharing the session traceId —
  codeburn.session.attribution (git.repo, git.pr_links, git.commit_count)
  and codeburn.commit (git.sha, git.in_main, git.was_reverted). Resource
  attribute codeburn.attribution_methodology=timestamp-window marks the
  attribution as inferred.
- push: generic send core reused by usage and attribution batches. Dedup
  keys encode mutable state (inMain/wasReverted), so a state transition
  re-sends the updated fact while identical states dedupe via the
  existing sent-ledger.
- cli: opt-in --attribution flag on sync push (dry-run aware); commits
  in repos with no network remote are never sent.

AI-Origin: human
…paths, PR-link validation

Review findings on the --attribution PR:

- Privacy: sessions whose project path no longer resolves inherited the
  cwd-fallback repo identity, egressing whatever (possibly confidential)
  repo the user pushes from and falsely attributing its commits.
  buildRepoGroups now tracks per-session identity provenance; the
  attribution path excludes fallback sessions from commit attribution
  entirely (no repo, no commits, PR links only) — they also can no
  longer steal a commit from a genuine session's window.
- Privacy: Windows drive-letter paths (C:/..., C:\..., drive-relative)
  parsed as scp-like remotes, emitting local filesystem paths as repo
  identities. normalizeRemoteUrl rejects drive letters and
  single-character hosts (dotless intranet hosts still accepted).
- Hardening: PR links are shape-checked before sending (https,
  /org/repo/pull/N path, <=256 chars, max 20 per session) — upstream
  parsers only truthiness-check them.
- Safety valve: MAX_ATTRIBUTION_PER_PUSH (10k) caps a first
  --since all --attribution push; dry-run reports the cap.
- Tests: adversarial normalize corpus, cwd-fallback egress repro,
  commit-stealing prevention, PR-link sanitization, and CLI-level tests
  (mock IdP + collector): dry-run sends nothing to the traces endpoint,
  flag-off emits no attribution span names on the wire.
- Docs: reconciled the 'never sent' wording with reality (PR links ride
  even when repo is null; device_id/methodology/timestamps disclosed).
  CHANGELOG Unreleased entry added.

AI-Origin: human
…CLI hardening

Review rounds 2-3 + self-review on --attribution:

Credential egress (round 2):
- normalizeRemoteUrl: scp userinfo expressed as an optional regex group
  let backtracking re-parse a credential prefix as host:path
  (x-access-token:ghp_...@host/repo -> token in git.repo). Userinfo is
  now split off at the first @ BEFORE any host matching.
- Positive validation (allow-list) as the final gate on EVERY branch:
  host must be hostname-shaped, every path segment repo-shaped, total
  identity <= 200 chars. Kills transport-helper remotes (ext:: leaks
  local SSH key paths, codecommit:: leaks AWS profile names), residual
  @, spaces/colons, and unbounded strings.
- sanitizePrLinks: links are rebuilt from origin + pathname — userinfo,
  query strings, and fragments are dropped instead of passed through;
  collapsed duplicates dedupe.

Attribution correctness (round 3 + self-review):
- Double-count fix with precise retraction semantics: when a commit
  migrates to a later-parsed tighter-window session, the loser re-emits
  git.commit_count=0. Empty records are emitted ONLY on a true loss in
  THIS computation (lostCandidacy) — a commit that merely aged out of
  the --since range was lost to nobody, and retracting it would
  permanently zero a still-correct server-side count. The sync layer
  additionally requires a prior ledgered state for the session.
- Session dedup key includes project + both window timestamps, so
  ongoing sessions re-emit with corrected span times.
- Span end times clamped like the usage builder (never 0, never
  earlier than start + 1ms).
- CLI mirrors the usage path on attribution push failures instead of
  claiming success.
- Identity normalization: case-insensitive .git strip, doubled path
  slashes collapse.

AI-Origin: human
@Enclavet
Enclavet force-pushed the feat/sync-yield-attribution branch from 28ac117 to 50c8251 Compare August 2, 2026 13:05
@Enclavet

Enclavet commented Aug 2, 2026

Copy link
Copy Markdown
Author

All findings from both rounds are fixed in 50c8251. Point by point, then one additional bug I found while re-auditing that I want to flag explicitly.

Housekeeping first: the branch was rebased onto current main and the fix rounds squashed, so earlier inline comments will show as outdated. Final shape is three commits: the feature (1bf7206), round-1 fixes (ccee28a), and rounds 2–3 (50c8251).

Round 2 — credential egress

1. Backtracking credential leak — fixed with your suggested approach. You were right that the @-exclusion in the host class was insufficient: the flaw was expressing userinfo as an optional regex group at all, which lets backtracking abandon it and re-parse the credential prefix as host:path. Userinfo is now split off at the first @ before any host matching happens. All three of your payloads return null: x-access-token:ghp_…@…, the one-dropped-slash https:/user:token@…, and oauth2:glpat-…@…. Each is a named test case.

2. Transport-helper remotes / no shape guard — fixed with positive validation. A final allow-list gate now runs on every branch's output: host must be hostname-shaped, every path segment repo-shaped ([A-Za-z0-9._~-]+), total identity ≤ 200 chars. ext:: (local SSH key path), codecommit:: (AWS profile name), residual @, spaces/colons, and unbounded strings are all rejected. Rejecting is always safe here — an unrecognizable remote simply has no server-side identity. Dotless intranet hosts and GitLab subgroup paths still normalize.

3. sanitizePrLinks passthrough — fixed by reconstruction. Links are rebuilt from origin + pathname; userinfo, query strings, and fragments never survive, and links collapsing to the same rebuilt URL dedupe. One deliberate semantic change: the input bound moved 256→512 so a legit link carrying a long notification_referrer_id is now sanitized and kept rather than dropped (the rebuilt form stays capped at 256).

Beyond the unit corpus, I ran a fuzz verification: 225 token-bearing remote forms (5 token styles × 5 userinfo prefixes × 9 templates including malformed https and transport helpers) — zero tokens in any output — plus a full-pipeline run against real repos with planted tokens, scanning every wire byte: tokens and usernames absent, identities and sanitized PR links still transmitted.

Round 3 — attribution correctness

4. Stale session double-count — fixed with retraction spans. When a commit migrates to a later-parsed tighter-window session, the losing session now re-emits with git.commit_count: 0. Mechanism: empty records are kept as retraction candidates, and the sync layer sends one only when a prior state for that session is already ledgered. The full migration scenario is a test: push 1 ledgers session A owning the commit; push 2 with tighter session B yields exactly [A:session(count=0, new key), B:session, B:commit]. Docs now state the receiver contract: upsert commits by (git.repo, git.sha), session rows by ai.session_id, latest state wins.

One refinement you should scrutinize: while testing this I found the naive version of the fix ("retract whenever a previously-sent session computes empty") introduces a worse bug — a commit that merely ages out of a rolling --since window computes as empty, fires a retraction, and permanently zeroes a still-correct server-side count (the original state key stays ledgered and is never re-sent). So retraction is gated on lostCandidacy — the flag attributeCommits already tracks, true only when a commit in this run's window was won by a tighter session. Losing to the range boundary is losing to nobody. Both directions are pinned by tests: aged-out commits produce no record and nothing sendable even with planted prior ledger state; a true tighter-window loss emits the retraction.

5. Session dedup key omissions — fixed. The key now hashes project + both window timestamps, so an ongoing session whose window grew re-emits with corrected span times. Accepted cost worth noting: an active session mints one new ledger entry per push while its window grows; the 6-month ledger prune bounds it.

6. Lying summary line — fixed. Rate-limited and server-error attribution outcomes now print the same style of message as the usage path, and the summary line reads N facts synced (push incomplete — remainder retries next push) instead of claiming plain success.

7. End-time clamp — fixed, matching the usage builder's guarantee: never 0, never earlier than start + 1ms. Tested with out-of-order and malformed timestamps.

8. .GIT / doubled slashes — fixed. Case-insensitive .git strip and internal slash collapse (previously a doubled-slash remote was rejected outright by the allow-list's empty-segment check; now it normalizes to the single correct join key).

Verification

tsc clean; 127 tests across the yield/sync/attribution suites (34 attribution-specific: unit corpus with every payload from both rounds as named cases, plus CLI-level tests against a mock IdP + collector). computeYield behavior and the sendBatches contract remain untouched.

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.

3 participants