Skip to content

feat(discrepancies): Discrepancy Center at scale — overview, real fixes, batch decisions, live-layer git safety - #146

Merged
siracusa5 merged 24 commits into
mainfrom
c/discrepancy-console
Aug 19, 2026
Merged

feat(discrepancies): Discrepancy Center at scale — overview, real fixes, batch decisions, live-layer git safety#146
siracusa5 merged 24 commits into
mainfrom
c/discrepancy-console

Conversation

@siracusa5

Copy link
Copy Markdown
Collaborator

Summary

Opening the Review tab on a real vault showed ~1,500 discrepancies and nothing to do about them except acknowledge one at a time. This PR makes the Discrepancy Center usable at that scale — engine and console together (three stacked pieces: read side + F30, write side, console).

Engine — read side (packages/core)

  • Discrepancy decision handling extracted from service.mjs into control/discrepancies.mjs (createDiscrepancyOperations), the control-plane pattern; routes are shims.
  • One memoized projection (keyed on corpus key + source-health signature + sidecar file stats + in-process write counter) feeds list, compact list, summary, detail, the decision guard, batch, and automatic rules — no more full re-projection per request.
  • GET /api/discrepancies?fields=compact&status=&kind=&…&limit=&offset= (envelope adds summary, total, filtered, projectionRevision), ?id= detail, /api/discrepancies/summary (counts by kind / status / source pair / owner / concept type, top targets, top concepts, quick wins). Bare GET byte-compatible.
  • Broken-link candidates: candidates[] + bestCandidate computed structurally (relative path, case, extension, slug, folder move, title alias, bounded edit distance) — never model-inferred; pure rewriteLinkTarget / removeLink.
  • F30 closed: every decision that writes inside the live team layer goes through git-core (commitPathsWithMutation, --literal-pathspecs, skipIfClean), commits only the written paths, pushes after the manifest lock is released (queues offline), and recovery restores under the repo lock and commits the restore.

Engine — write side

  • New broken-link actions: rewrite_link {newTarget}, unlink, create_stub {layer, title?, type?} (create-mode staged writes with journal recovery of created targets; case-conflict and traversal refused). Rewrites touch only the effective contributor's section (rationale in the note).
  • POST /api/discrepancy-decisions/batch (≤500): one lock, one projection, all revisions validated before anything is applied, per-item transactions, continue-on-error, dryRun (wouldWrite), 10 s lock budget → notAttempted for resubmission, one push per request, per-item results in input order.
  • runAutomaticRules runs as one batch (was one item per index pass).
  • Rules: conceptType/key may be "*" only for broken links (target is always exact); rewrite_link rules; generalized suggestions from ≥3 consistent decisions spanning ≥2 (conceptType, key) pairs.

Console (apps/console)

  • Overview header tiles (actionable, per kind, quick wins) that apply filters; status tabs with counts; group by kind → target / concept / source pair / owner; virtualized grouped list (useVirtualWindow, roving tabindex, Shift-range, group checkbox) — 1,500 rows no longer mount 1,500 DOM nodes.
  • Compact fetch + detail-on-select (LRU), one shared actionable predicate, summary mirror for demo/old engines.
  • Bulk bar: acknowledge N, rewrite N links → candidate, remove N links, create <target> in a layer, use <source> for N — every bulk action previews via dryRun ("N files change across L layers") before Apply; receipt "N done · M need attention · K not attempted", failures stay selected; batch chunking with partial-result reporting.
  • Broken-link panel: suggested fix (best candidate), other candidates, remove link, create stub, acknowledge; Canvas quick-resolve popover offers the fix instead of a guaranteed 409.

Docs / specs

  • docs/architecture/notes/discrepancy-projection.md (memo, F30 placement, batch semantics, wildcard rule); discrepancy-center + control-plane specs amended; site reference/discrepancy-api.md rewritten to the shipped contract; root CLAUDE.md rows + gotchas.

Review

Adversarial reviews at each stage (engine read/write safety pass; console pass) → fixes landed: push moved outside the manifest lock, git-core mutate() inside its rollback try, recovery restore under the repo lock, * pinned to broken links, create_stub case-conflict 409, batch git summary guard, atomic team-rules write, blocked rows not decidable; console: stale dry-run selection guard + locked rows during a batch, partial chunk results honored, isBrokenLink migration completed (quick-resolve, filters, reason options), optimistic flips update the summary, selection preserved on receipt, create-stub reporting, per-id retry timers, batch-scoped rule suggestions.

Verification

  • Engine: node --test over discrepancies / projection / link-actions / batch / transactions / conflict-resolutions / layer-files / git-core → 75/75; bash packages/core/tests/service-test.sh → pass; bash packages/core/tests/discrepancy-live-layer-test.sh (single + batch commits, offline queue, sync, held lock, recovery commit) → pass; git-sync-test.sh, resolve-all-scale-test.sh → pass. Root npm test: only slow/graph-latency red on this machine — fails identically on the base commit under load (environmental).
  • Console: npm run typecheck && npm test && npm run build → 550 tests pass; live-verified against this branch's engine: tiles/grouping from the compact envelope, detail via ?id=, bulk "Rewrite 3 links → …" dry-run → apply rewrote both files, create-stub resolved its links.
  • bash .github/scripts/check-dco.sh origin/main → OK (21 commits).

Known follow-ups (not in this PR): MCP list_discrepancies / --resolve write tools for agents (plan Issue 8); the Files editor's live-root writes still bypass git-core (documented in CLAUDE.md).

🤖 Generated with Claude Code

siracusa5 and others added 24 commits August 18, 2026 17:56
…epancies.mjs

Extract the decision, rule, priority, automatic-rule, and recovery machinery
out of service.mjs into createDiscrepancyOperations(caps), following the
control/sources.mjs pattern: the HTTP routes are parsing shims, the operations
carry every guard, refusal message, and journal transition. Capabilities are
injected (file roots, selected layers, stores, projection, git, onWritten) so
the CLI can host the same operations without an HTTP service.

Behavior-preserving: every status, message, and journal state is unchanged;
refusals are ControlError with a stable code and the same HTTP status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…s for discrepancies

discrepancies.mjs gains the pure pieces the Discrepancy Center at scale is
built from:

- buildLinkIndex/candidatesFor: structural repair proposals for a broken
  link's target — relative, case, extension, slug, moved, title, slug_moved,
  and a bounded typo search (banded OSA distance, histogram reject, longest-
  prefix-first scan cap) — attached to every broken_link record as
  `candidates` (≤5, confidence then id) and `bestCandidate` (≥0.85 and
  unambiguous). Never part of `revision`. 1,500 links over 3,000 ids in
  ~300ms, deterministic.
- rewriteLinkTarget/removeLink: edit link syntax in section text preserving
  ./, .md, #anchor, whitespace, and wikilink aliases; images untouched.
  Consumed by the write actions in the next PR.
- ACTIONABLE_STATUSES, summarizeDiscrepancies, compactDiscrepancy,
  filterDiscrepancies: the summary, list-row, and filter shapes the compact
  route answers with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… and detail reads

The projection (buildDiscrepancies over the resolved corpus plus this
profile's decisions, rules, and priorities) now lives in
control/discrepancies.mjs behind a `corpus` capability and is memoized in one
place, keyed `corpusKey | sourceHealthSig | sidecarRevision` (size:mtimeMs of
the decision log, local rules, team rules, and priorities, plus an in-process
write counter every write path bumps). The list route, the decision guard,
and the automatic-rules job all read the same build; a memo hit still reports
the live generation. Idle-evicted like the corpus memo.

Routes, additive — the bare GET /api/discrepancies envelope is unchanged:
  GET /api/discrepancies?fields=compact&status=&kind=&conceptId=&target=&source=&owner=&conceptType=&limit=&offset=
      → compact rows (240-char previews, historyCount + latestDecision) plus
        summary, total, filtered, offset, limit, projectionRevision
  GET /api/discrepancies?id=<id>  → { discrepancy | null, generation, projectionRevision }
  GET /api/discrepancies/summary  → { summary, coverageComplete, indexing, indexingSources, generation, projectionRevision }

statusApi() is split into statusOf(manifest, pinned) so the corpus provider
hands the projection a corpus and the status it was resolved under from ONE
pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… git-core (F30)

A decision whose contributor sits inside the live team layer used to rename
new bytes into the shared clone with no lock, no commit, and no push — the
next pull saw a dirty tree and teammates never received the change. The
control operation now detects live targets (resolveLiveLayer over the
selected layers), and stages AND commits inside git-core's
commitPathsWithMutation: one pathspec commit per decision
(`chore(contextcake): resolve <kind> <conceptId>#<key> (<action>)`), the
decision records `liveLayerCommit: { layer, paths }`, the response carries
`git: { layer, paths, committed, pushed, queued }`, push runs once per
request after the decision is durable, and an unreachable remote is
`{ pushed: false, queued: true }` — never thrown. LockBusy answers 409
`LIVE_LAYER_BUSY` with nothing staged, journaled, or renamed. Staging moved
inside the lock deliberately: the read that backs a target up must not race
another process's pull.

git-core gains `skipIfClean` so a decision that writes a layer's own winning
bytes back to it (byte-identical file) applies without an empty-commit
refusal. The journal's recover() gains an `onRestored` hook, run after the
backups are copied and before they are dropped, so the crash-between-commit-
and-append edge commits its restore (`chore(contextcake): roll back
uncommitted discrepancy transaction <id>`) and git agrees with the log; a
failed hook leaves the transaction retryable. Journal containment now
compares real paths — the writers record realpaths, and a symlinked layer
path (every macOS temp dir) made recovery refuse its own targets.

POST /api/sources/sync now handles the live layer (withGitSync's sync()
lands the queue). readLayerSection joins layer-files.mjs and is wired as the
`readLiveSection` capability the write actions in the next PR consume.

New suite: discrepancy-live-layer-test.sh (bare + clone; commit/push/tree
clean; skip-if-clean; offline queue + sync; held lock → 409; recovery
commit). Spec, CLAUDE.md, and docs/architecture/notes/discrepancy-projection.md
carry the reasoning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…are restore after a landed commit

Review follow-ups on the F30 path:

- commitDecisionWrite is now the single stage -> journal -> rename -> append
  -> finalize -> push tail. applyDecision uses it, and the legacy "change a
  past decision" branch of POST /api/conflict-resolutions goes through it
  too, so a changed decision whose contributors include the live team layer
  commits under git-core's lock like every other decision (it was the last
  decision path still renaming into the clone bare). The live-layer suite
  exercises the route.
- If the git commit lands and the decision append then throws, the restore
  is itself a commit under the same lock ("roll back uncommitted discrepancy
  transaction <id>"), never a bare copy that leaves HEAD holding a write the
  log denies. Once the decision is appended nothing rolls back: a journal or
  cleanup failure after that point is logged and startup recovery reconciles
  the prepared record against the committed decision.
- git-core passes --literal-pathspecs on add/commit/status: the paths are
  concept ids, and a "[" in a name is a glob.
- promoteRule guards a host without the git capability the way applyDecision
  does; the candidate memo key uses escaped NUL separators (raw NUL bytes
  fail the repo's grep-hygiene gate); the scale bound is 3s to catch a
  blowup without flaking on a loaded runner; the live-layer suite takes an
  unused port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… targets

The staged-transaction protocol behind every discrepancy write only knew how
to REPLACE an existing file (stage beside it, back it up with COPYFILE_EXCL,
rename into place, restore from the backup). "Create the missing concept"
for a broken link needs a CREATE: stage the new bytes with no backup, place
them with an exclusive copy so a file that appeared since staging is never
overwritten, and roll back by unlinking exactly what this transaction placed
— never a file it did not (a created target whose commit failed on EEXIST is
someone else's).

- layer-files.mjs: stagePreparedWrites accepts `write.create === true`
  (target `{ path, staged, backup: null, created: true }`), `options.probe`
  answers the would-be targets without touching the disk (a batch's dry
  run), and stageFileCreationTransaction({ layer, rel, text }) is the guarded
  creating writer — same sandbox (resolveLayerFile), same size cap, refuses
  an existing path (lstat, so a dangling symlink counts) at stage time and
  again at commit, creates the parent folder. stageFrontmatterTransaction
  passes options through so it can be probed too.
- conflict-resolutions.mjs: recover() restores a `created` target by unlink
  (bounded to the prepared window — documented), skips a `null` backup
  instead of resolving "null" as a path, and checks containment of every
  journaled file before touching it.
- http-util.mjs: assertInsideRoot resolves a not-yet-existing path through
  its deepest existing ancestor (realpathLenient, now exported and shared
  with the journal). Resolving only the immediate parent refused a contained
  file two folders deep whenever the root sits behind a symlink — every
  macOS temp dir — which is exactly the shape of a stub in a new subfolder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…neralized suggestions

A rule could only say "acknowledge every broken link to old/decisions from
the team layer in decision#choice" — one rule per section key. It can now say
it for every section: `conceptType` and `key` accept the literal wildcard
"*"; `kind`, `sources`, and a broken link's `target` stay exact. The target
is never wildcarded — a target-blind rule is exactly the auto-acknowledge-a-
typo failure the existing comments warn about — and validateRule refuses
`target: "*"` outright.

- discrepancy-rules.mjs: `{ type: "rewrite_link", newTarget }` is a valid
  action for broken_link only; the destination is normalized and must
  differ from the missing target; prefer_source is still refused for broken
  links. suggestDiscrepancyRules keeps the exact-pattern miner and adds a
  generalized suggestion (`generalized: true`, match conceptType "*" / key
  "*") only when three or more consistent acknowledge/rewrite_link decisions
  against one (sources, target) span at least two distinct (conceptType,
  key) pairs, and no equivalent rule exists. serializeRuleDocument round-
  trips both shapes.
- discrepancies.mjs: matchRules treats "*" as any for those two fields;
  ruleConflict is unchanged — a wildcard and an exact rule that disagree
  both match and disable automation, no specificity order is invented. A
  resolved broken-link row keeps its `target` (from the decision's
  linkTarget) and a rewrite's newTarget as its effective value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…tomatic rules as one batch

A broken link could only be acknowledged; the engine refused to fix it
("Open the source file to repair this link"). For 412 links to a renamed
concept that was 412 manual edits. control/discrepancies.mjs now applies
three link actions and a batch, and the automatic-rules job runs through
the same batch instead of one apply per index pass.

Write actions (applyDecision → applyLinkDecision):
- `rewrite_link { newTarget }` and `unlink` edit exactly the effective
  contributor's section — the one contribution a broken_link record has;
  a dissenting lower-layer copy is not a discrepancy today and surfaces as
  its own record with the same candidates if precedence flips. The section
  is read LIVE (readLiveSection), not from the projection, so two rewrites
  into one section in one request both land; `replaced === 0` is 409
  LINK_GONE. `newTarget` is normalized and must exist in the projection
  (409 LINK_TARGET_MISSING); the layer must be writable (409
  SOURCE_NOT_WRITABLE). Same staged transaction and write tail as every
  other decision (commitDecisionWrite: journal, rename, git-core inside the
  live layer, push).
- `create_stub { layer, title?, type? }` writes a minimal OKF document
  (`type`, `title`, `updated`, the H1, one line saying why it exists) into
  a writable layer through the create-mode transaction: traversal-guarded
  path, 409 TARGET_EXISTS when anything is already there (lstat), the
  parent folder created, `created: true` on the journal target and on
  `writtenTargets`. Every link to that target then resolves; the created
  concept is the audit trail for the ones this decision did not record.
- Kind gate both ways: choose/compose on a broken link → 409
  BROKEN_LINK_NOT_WRITABLE ("A broken link has no alternative answer to
  choose or compose. Rewrite the link, remove it, create the missing
  concept, or acknowledge."); a link action on any other kind → 400
  ACTION_INVALID. Decisions carry `sectionKey`, `linkTarget`, `newTarget`
  / `createdTargets`, a human `reason`, and `ruleAction` only for
  rewrite_link (unlink and create_stub are deliberately not learnable). A
  `resolved` row is NOT_OPEN — it is the audit trail of a past decision,
  and its recorded revision would otherwise pass the STALE check into a
  write path with no effective source.

Batch (`POST /api/discrepancy-decisions/batch`, decideBatch):
`{ decisions: [≤500], stopOnError?, dryRun? }` → `{ ok, applied, failed,
dryRun, results: [{ discrepancyId, ok, status?, code?, error?, decision?,
written?, git?, wouldWrite? }], git?: { layer, commits, pushed, queued },
suggestions }`. One manifest lock, one settled projection (the whole batch
is 409 COVERAGE_INCOMPLETE while indexing), every item validated first
(shape, NOT_OPEN, STALE, DUPLICATE) — nothing applied until all are looked
at — then applied in order, each its own journal transaction, continuing
past a failure unless stopOnError (the rest come back SKIPPED; a
RECOVERY_REQUIRED stops the batch regardless). dryRun runs every pre-check
through the stage's `probe` and answers `wouldWrite` without touching a
file, the log, or git. The host is told once (onWritten), the live layer
is pushed once, and the response carries the rule suggestions the new
decisions support. Over 500 is 413 BATCH_TOO_LARGE for the whole request.

Automatic rules: the eligible list is built from one projection and applied
as ONE batch under ONE lock (the batch answers from the projection already
built), each item re-checked against the locked projection before it
applies (still auto_ready, single automatic rule with the same id and
action, sources healthy, writable — every existing guard, now including a
rewrite's effective layer), a failed apply appended as `blocked` for that
revision exactly as before, bounded at 500 per pass, one push. rewrite_link
rules map to `{ action: "rewrite_link", newTarget, ruleId }`; a destination
that no longer exists blocks with a record, never acknowledges silently.
The one-per-pass `return` is gone.

The projection memo key now names the aggregate `indexing` flag beside the
per-source health (in the service it is derived from those, so no extra
miss; a host whose flag moves on its own gets a fresh coverage answer).

Tests: discrepancy-link-actions.test.mjs (in-process service: rewrite
touches only the effective file, sequential rewrites in one section, missing
/ invalid target changes no bytes, unlink, create_stub with wx / occupied
path / traversal / new subfolder / projection after refresh, the kind gate
messages, recovery of a created stub after a crash), discrepancy-batch
.test.mjs (stubbed git: mixed batch with per-item results and one push,
stopOnError, dryRun, envelope validation, two rewrites in one section,
automatic rules in one pass with one projection build and a blocked record,
the under-lock re-check), service-test.sh (batch route: 405 gate, 413,
dry run, rewrite + unlink + NOT_OPEN per item, the file), discrepancy-live-
layer-test.sh (a batch of two rewrites into the live layer: two commits,
ONE push counted on the bare's reflog).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ns, and rule wildcards

- specs/contextcake-discrepancy-center: criterion 6 gains rewrite_link /
  unlink / create_stub through the same staged transaction, scoped to the
  effective contributor and read live; criterion 11 gains `*` on
  conceptType/key only from multi-(type, key) evidence, target always
  pinned, the rewrite_link action; new write-side criteria for the batch
  (one lock, one projection, validate-all-then-apply, per-item transactions,
  dry run, one push, suggestions), automatic rules as one batch, and the
  create_stub recovery caveat; the Web Demo criterion covers a batch; out of
  scope names the effective-section-only scope and wildcard targets.
- specs/contextcake-control-plane §5.8: `discrepancy batch` with `--dry-run`
  and `--stop-on-error`; the engine ships decide/decideBatch/
  runAutomaticRules, the CLI adapter is pending.
- docs/architecture/notes/discrepancy-projection.md: a "Batch and write
  actions" section — why per-item transactions, why dryRun rides the real
  path, why the automatic job is the same batch, why a rewrite touches only
  the effective contributor and reads live, why create_stub resolves more
  than its record, why `target` is never wildcarded; the memo key now names
  the aggregate indexing flag.
- CLAUDE.md: the control/discrepancies.mjs, discrepancies.mjs, and
  discrepancy-rules.mjs rows; gotchas for the batch, the live read, the
  creating writer, and the wildcard rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ck hold, and keeps a target group's candidate

Review findings on the batch and the write actions, each with a test:

- A resolved broken-link row (now carrying its `target`) entered its target
  group in summarizeDiscrepancies and, having no candidates, flipped the
  group's `agree` off — the "rewrite the rest → X" default vanished the
  moment the first link in a group was fixed. The shared bestCandidate is
  now agreed over the ACTIONABLE rows only; `count` still includes resolved
  rows.
- Phase 1 of the batch checked shape/NOT_OPEN/STALE/DUPLICATE but left every
  parameter check (kind gate, reason, source, content, rewrite target
  existence, stub layer/title/type) to the apply phase — so with
  stopOnError a bad third item could follow two applied ones, contradicting
  "validated before applying any". Those disk-free checks now live in
  validateDecisionParams, run in Phase 1 and again at the top of
  applyDecision. For an automatic item a parameter failure after the guard
  is recorded `blocked`, exactly as an apply failure is.
- create_stub's dry run was hand-rolled and skipped the creating writer's own
  TEXT_EXT/size checks; it now goes through probeWrite(stage) like every
  other action. A stub whose id has an empty, dot-prefixed, or node_modules
  segment — a file the indexer would never read back, leaving the link
  broken while the decision says resolved — is TARGET_INVALID up front.
- The batch held the manifest lock for its whole length; the lock's waiters
  give up after 15 s and a source add spins the event loop while it waits.
  The apply loop now has a wall-clock budget (BATCH_TIME_BUDGET_MS, 10 s,
  injectable): items not reached come back BATCH_TIME_BUDGET — counted as
  `notAttempted`, not `failed`, alongside SKIPPED — for the caller to
  resubmit; never before the first attempt, so automatic work always makes
  progress. `ok` is `failed === 0 && notAttempted === 0`.
- The automatic job swallows COVERAGE_INCOMPLETE from the batch (a pass
  landing while it waited for the lock is a normal condition, as the old
  loop's silent return treated it) instead of logging a failure every pass.
- Recovery of a `created` target compares the file against the staged copy
  while that copy is still beside it and leaves a file whose bytes differ
  alone (someone's edit, not our placement); the unconditional unlink is
  now only the no-staged-copy case.
- The single-route error envelope carries the ControlError `code`
  (additive), so a client can branch on the single routes as it can on
  batch items. Unexpected (untyped) errors inside a batch item are logged,
  not just folded into a 200 per-item result.

Tests: the re-check guard test now actually reaches the guard (the job plans
while the test holds the manifest lock, the rule is disabled, the lock is
released); LINK_GONE and SOURCE_NOT_WRITABLE per item; the time budget
(applies one, budgets the rest, dry runs exempt, resubmit finishes); each
Phase-1 parameter code; create_stub into the live layer (one pathspec commit
of exactly the new file); the target-group candidate with a resolved row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…a bracketed title stays a string

stageFileCreationTransaction turned an ENOTDIR from mkdir (a `guides.md`
where `guides/` has to be) into a raw 500; it is a 409 naming the file in
the way. renderStub quotes a title that starts with `[` or a quote so the
engine's own frontmatter parser (which reads `[…]` as a list and strips one
pair of quotes) hands it back as the string it is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…ldcards to broken links

Safety review of the combined engine branch (PR 2 + PR 3), nine items:

1. The live-layer push ran under the manifest lock (commitDecisionWrite for
   every decision and the legacy conflict route; decideBatch's summary). A
   slow or offline remote is up to three 90 s network calls — long enough
   to time out every concurrent control-plane write, spin the event loop
   for the sync manifest holders (POST /api/sources, PATCH /api/settings),
   and past the 60 s stale threshold let another process steal the lock.
   commitDecisionWrite now returns a `git.pushRoot` marker; decide,
   decideBatch, runAutomaticRules, and the legacy route push once through
   pushAfterUnlock after the lock is released (as promoteRule always did).
   Test: a stubbed slow push — a concurrent priority write completes while
   the push is pending; the marker never reaches a response.
2. git-core.commitPathsWithMutation ran `await mutate()` outside its try, so
   a mutation that threw halfway skipped the locked rollback and left the
   compensating restore to run after the lock was gone. The mutation is
   inside the try now; commitDecisionWrite's catch already skips a second
   rollback (rolledBackUnderLock). Test: git-core.test.mjs (new suite) — a
   halfway failure rolls back under the lock, no litter, error propagates.
3. Startup recovery restored live-layer bytes before taking the repo lock and
   only then committed. The journal's recover() now hands its restore step
   to a `restore(tx, targets, applyRestore)` hook; the operation restores
   live-layer targets inside the commitPathsWithMutation mutate that commits
   the restore, non-live targets on the plain path. Tests: the hook is
   entered before any byte moves and finalizes only after it returns; with
   a stub git the bytes are still the crashed ones when the locked mutation
   begins and restored when it ends.
4. validateRule accepted conceptType/key "*" for section_content and
   frontmatter_value — pinned by kind and sources alone, one flip to
   automatic would overwrite every disagreement between two layers. The
   wildcard is broken-link only now (the exact target is what still pins
   it); matchesField unchanged. Tests both directions.
5. create_stub on a case-insensitive filesystem: the resolver folds
   `Guides/Deploy.md` onto an existing `guides/`, so the created concept
   would not be the link's target and, in a live layer, the live-relative
   assertion threw a bare Error. relativeWithinRoot (layer-files.mjs)
   compares the path that would land against the one requested, byte for
   byte → 409 TARGET_CASE_CONFLICT; the assertion is a typed 409
   LIVE_TARGET_MISMATCH that the rollback path preserves. Unit-tested on
   the helper and end to end (branching on the fixture's own filesystem).
6. decideBatch re-resolved liveLayer() after the loop and dereferenced it
   unguarded; it is captured once before the loop and guarded.
7. promoteRule truncated-and-wrote the team rules file every projection
   parses; it writes temp + rename.
8. `blocked` rows rebuilt from the log alone were still decidable; NOT_OPEN
   now covers every synthetic status (resolved, blocked). Test.
9. apps/site reference/discrepancy-api.md updated to the shipped contract:
   compact/summary/?id= reads, the three link actions, error codes, the
   batch envelope (notAttempted, BATCH_TIME_BUDGET, dryRun, one push),
   rules `*` (broken-link only) and rewrite_link.

Docs: CLAUDE.md gotchas, spec criteria 11 and the live-layer criterion, and
the projection note (push after the lock; restores inside git-core's).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…screpancy wire types

`discrepancy-summary.ts` is the pure half of the Discrepancy Center:
`ACTIONABLE_STATUSES` / `isActionable` (the predicate Overview, Sidebar,
Header and Conflicts each carried their own copy of), `summarizeConflicts`
(a mirror of the engine's summarizeDiscrepancies so the demo bundle and an
engine without the compact route draw the same header), `groupConflicts`
(kind / concept / source pair / owner, broken links sub-grouped by target,
largest group first), `describeItems` (what a selection has in common —
the facts every bulk action is gated on) and `buildHaystack`.

types.ts learns the compact row (`truncated` / `valueBytes` / `valueKind`,
`historyCount` + `latestDecision`, `compact`), broken-link `candidates` /
`bestCandidate`, the summary envelope, the `?id=` detail response, the
three link actions and the batch request/response. The Conflict view model
gains `detailLoaded`, `conceptTitle`/`conceptType`, and the candidate
fields. Overview's Discrepancies tile reads `summary.actionable` with a
per-kind subtitle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…isions

The store now fetches `/api/discrepancies?fields=compact` — every row's
identity, status, revision, candidates and ≤240-char previews plus the
engine's summary in one envelope — and loads a row's full record
(`?id=`, history + untruncated values) when it is selected, through the
same bounded LRU shape as `loadConceptDetail`. An engine that ignores the
parameter answers full records with no summary; the adapter reads both,
and the store computes the summary locally in that case (and for the demo
bundle). `conflictSummary` sits in the data context as state, so its
identity only moves when a payload lands (`NO_SUMMARY` before the first).

`decideDiscrepancies` posts to `/api/discrepancy-decisions/batch`: one in
flight, optimistic status per ok result, ONE refetch afterwards; a dry
run changes nothing here. A 404 on the batch route (an engine older than
it) falls back to sequential single decisions and says so in the answer,
so a dry run against such an engine is honestly not a preview. The demo
source runs the same loop over its simulation. `LiveDataError` carries
the engine's machine `code` when the body has one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Split views/Conflicts.tsx into views/conflicts/: OverviewHeader (tiles —
actionable, per kind, quick wins — each one a filter; status tabs with
counts from the summary; group-by), GroupedList (a windowed grouped list
over the new components/useVirtualWindow — FileTree's technique
generalized to variable heights with a prefix sum and a binary search;
group rows 44px, item rows 128px; one roving tab stop, Arrow/Home/End,
Left/Right close/open a group, Space toggles selection, Enter opens,
Shift+click ranges, a group checkbox selects the group; the active row is
spliced in even off-window so focus survives the wheel), BulkBar (act on
the selection: acknowledge with a reason, rewrite N links to a candidate,
remove N links, create the missing concept in a writable layer, use one
source for N — every one previewed through `dryRun` first, then applied;
failures stay selected), DecisionPanel (moved; a skeleton until the full
record is in; the broken-link panel gains the suggested fix, the other
candidates, remove-link and create-stub beside the original acknowledge /
open-source pair), Evidence (diffs, history, skeleton) and Rules (`*`
reads as "any", rewrite actions as "Rewrite → target").

Search matches against one lowercase haystack per row built when the list
changes rather than re-stringifying every contribution per keystroke.
The list CSS becomes the windowed container (no more breakpoint rules
that turned it into a grid of cards). render-hygiene's row selector is a
descendant selector now that rows sit inside a positioned inner element;
the tab-label matchers in Conflicts.test tolerate the count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Conflicts.test: tiles apply filters, group counts and default collapse,
Shift+click range and group checkboxes, bulk-bar enablement rules,
dry-run confirm then apply (with `newTarget` on a rewrite), a partial
failure toast that keeps only the failures selected, the broken-link
panel's suggested fix / candidates / unlink / create_stub payloads, the
skeleton until the full record loads, keyboard travel across a group
boundary, and a 2,000-row window that keeps the focused row mounted
off-window (Files.test's virtualization case, ported).

store.test: the engine summary from the compact envelope, the selected
row's full record through `?id=`, the local summary for an engine that
ignored `?fields=compact`, and a batch that flips its ok rows once and
refetches exactly once (a dry run does neither). api.test: compact vs
full adapters, the compact fetch, `?id=` against new and old engines,
the batch route, the 404 sequential fallback and its `code`, a real 409
NOT falling back. Overview.test: the tile's actionable count and per-kind
subtitle. render-hygiene.live pins the summary's identity across
keystrokes.

Also: the demo simulation now keeps an acknowledged/composed row in its
tab on the refetch that follows a decision (it used to flip back to
Needs review a moment later), and the batch receipt reads
"N done · M need attention".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… shape

CLAUDE.md gains the architecture bullet (compact fetch + detail-on-select,
the windowed listbox with group-header options, the dry-run-then-apply
batch and its sequential fallback, where the pure helpers live) and three
gotchas: one actionable predicate, compact rows are previews (never
compose or diff from one), and the row heights that live in two places.
README's Resolve entry describes the tiles, grouping, bulk bar and the
suggested fix; the file map lists useVirtualWindow and views/conflicts/.

The batch receipt now quotes the first failure's own error, so "3 need
attention" says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…column tiles under 980px

The "Reset filters" button in the empty state keyed on object identity, so
the Actionable tile — which set a fresh copy of the defaults — left it
showing at defaults; it now compares field by field and the tile passes the
default object through. Below 980px the five tiles wrapped into three
columns with two empty cells showing the gap color; two columns fill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
… findings

Engine write side (PR 3) landed under this branch; the console now speaks
its final shape. Batch answers carry `notAttempted` (per-item `SKIPPED` /
`BATCH_TIME_BUDGET` — the engine's ~10 s lock budget ran out): those are
partitioned apart from failures (`partitionBatchResults`), the receipt reads
"N done · M need attention · K not attempted", and both tails stay selected
for resubmission. `wouldWrite[].created` counts as "(K new files)" in the
dry-run sentence. A selection past 500 goes over as consecutive batches
whose answers merge in input order (the engine 413s past that); the
sequential fallback reports its stopOnError tail as SKIPPED too. Decision
records' `newTarget` / `createdTargets` show in the history. `originalKind`
rides on the row and every broken-link branch asks `isBrokenLink` (a
reopened link is still a link — the engine gates the fix actions on
originalKind), and the summary mirror agrees a target's shared candidate
over actionable rows only, as the engine now does.

From the review: a batch always refetches, even when nothing landed (an
all-STALE answer means the rows hold revisions the engine no longer has);
`decideDiscrepancy` refuses — throws — while another decision is in
flight instead of resolving to nothing (a caller used to show a "Done"
receipt for a decision that never happened), and the decision panel parks
while ANY decision is in flight; a preview is keyed on revisions too, so a
refetch between Preview and Apply invalidates it; a loaded detail is
carried across a refetch when its compact row is unchanged (the open
compose field no longer drops to a skeleton when content moves elsewhere);
focus returns to the list's own tab stop; the selection lives inside the
tab/filters, not the search (which only hides, and says how many); item
rows are user-select: none so a Shift-click ranges rows, not text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
…atch results, finish the isBrokenLink migration

A dry run answers for the selection it was asked about: the bulk bar keys
the request on the selection's ids and revisions before the await and drops
an answer that no longer matches, and the list refuses to (de)select rows
while a batch is in flight (`selectionLocked`). A selection past 500 that
has a LATER batch refused keeps what the earlier batches applied — the
merged answer comes back with `error {chunk, message}` and the rest of the
selection reported SKIPPED, so the store still flips what landed and
refetches, and the receipt reads "500 done · … not attempted. Batch 2
failed: <error>"; a batch that reports RECOVERY_REQUIRED stops the ones
after it regardless of stopOnError, and suggestions merge across batches by
id. The receipt subtracts only the successes from the user's selection
(failures, the not-attempted tail and a create-stub's sibling links all
stay), says when a failure could not be identified, offers "Create rule"
only for a suggestion whose action (and, for links, target) is what the
batch did, and a group create-stub says what it sends: one decision, the N
selected links resolve on the next scan.

Every broken-link branch asks `isBrokenLink` now — the Cascade quick-resolve
popover (which offered a guaranteed-409 "use X's answer everywhere" for a
reopened link, and now offers the suggested fix), the panel's reason list,
the kind filter (`effectiveKind`, so the tile that counted a reopened link
as a broken link is the filter that shows it), and `describeItems`, whose
shared fix is agreed over actionable rows only, like the summary. A loaded
detail carried across a refetch keeps only its bodies and history and
takes candidates, rules, health and status from the fresh compact row; an
optimistic flip (single or batch) recomputes the summary locally so the
header and tab counts move at once; detail-load retries are one timer per
row, cleared on unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>

# Conflicts:
#	apps/console/CLAUDE.md
#	apps/console/src/api.test.ts
#	apps/console/src/api.ts
#	apps/console/src/store.tsx
#	apps/console/src/styles.css
#	apps/console/src/views/Overview.tsx
#	packages/core/src/service.mjs
…erministic

The apply loop compared elapsed > batchTimeBudgetMs; with the test's zero
budget a runner that finished the first item inside the same millisecond saw
0 > 0 as false and applied everything (CI did). "The budget is used up"
includes landing exactly on it; at the 10 s default the change is immaterial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
@siracusa5
siracusa5 merged commit 189d124 into main Aug 19, 2026
9 checks passed
@siracusa5
siracusa5 deleted the c/discrepancy-console branch August 19, 2026 04:51
siracusa5 added a commit that referenced this pull request Aug 19, 2026
Ships the four adoption-first UX PRs merged today: #142 hides every commerce
surface behind a build-time flag, #143 shows the cascade as a reorderable
position list, #144 adds seven theme families alongside ContextCake, and
#146 rebuilds the Discrepancy Center (candidates, batch decisions, and
closes F30 — live-layer writes now go through git-core).

#142 is site-only and already live via the ordinary site-deploy path; the
other three reach the Mac app.

Bumps all four version-aligned package.json files (root, desktop, and the
two npm CLI packages) plus distribution-artifacts.test.mjs's hardcoded
fixture version, per assertVersionAlignment's four-file check.

Signed-off-by: John Siracusa <siracusa5@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <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