Skip to content

feat: single thread-scope resolver + wire all four surfaces (GIT-69 item 1) - #20

Merged
nTEG-dev merged 10 commits into
mainfrom
feature/GIT-69-thread-scope-resolver
Aug 7, 2026
Merged

nTEG-dev merged 10 commits into
mainfrom
feature/GIT-69-thread-scope-resolver

Conversation

@nTEG-dev

@nTEG-dev nTEG-dev commented Aug 6, 2026

Copy link
Copy Markdown
Member

Draft — verification only. Do not merge. No release, no tag.

Opened per ruling R8b on GIT-69 to buy the full Node 18/20/22 CI matrix as free verification without a merge or publish surface.

What this does

Introduces src/services/thread-scope.ts — one definition of "the thread store, scoped" — and routes every surface that reads or resolves threads through it.

Four surfaces each computed their own view and disagreed in three ways that were invisible from any single call site:

before after
session_start panel limit 50, excluded dormant resolver: 100, dormant in scope
list_threads limit 100, included dormant resolver: 100
resolve_thread (by text) own project lookup resolver
dedup candidates no ORDER BY, project-wide resolver order, own_session

Those disagreements are the f02f74ca panel-vs-list defect and the nondeterministic dedup candidate set. THREAD_SCOPE_ORDER and THREAD_SCOPE_LIMIT are single constants, and the order carries a thread_id tiebreaker — without it, vitality_score: 1 on most rows leaves the candidate set unstable exactly at the limit boundary.

Rulings implemented

  • R1 — dedup candidates are the caller's own session only. With no active session the query is null and dedup is skipped, rather than widening to the namespace. The local-file fallback is held to the same scope so cache and store cannot disagree.
  • R7 — dormant is in scope, filtered at display. The panel cap is annotation, not scope: any capped or filtered view states what it omitted and where the rest lives.
showing 5 of 47 in scope (31 dormant, 11 over cap) — list_threads for full view

session_close's suggestion pass opts into "project" visibility as an explicit parameter, not a default — it proposes threads to a human rather than silently merging into one.

Verification

Check Result
npm run typecheck clean
npm run build clean
npm run test:unit 1042 / 1042 passed, 63 files (+38 new)

New coverage: resolver parity (query builder vs in-memory filter on shared fixtures), cross-project isolation including the f02f74ca leak as a guarded case, determinism, own-session candidate set, status sets, the recovery→scoping seam fixture, and 7 display-layer reconciliation cases asserting shown + dormantHidden + overCap === totalInScope.

Scope boundaries

  • Contains zero lines from wip/git-51-session-recovery (acceptance check on the diff, per R6).
  • syncThreadsToSupabase is deliberately not wired — sync's criterion is reconciliation, not visibility. Filed separately as GIT-70.
  • GIT-69 items 2 (thread-aware cache-health), 3 (atomic close), 4 (parent/scoped resolve) are not in this PR.

Refs GIT-69, GIT-63, GIT-67, GIT-70

Claude and others added 10 commits August 2, 2026 11:57
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One definition of "the thread store, scoped", per ruling R1/R6. The four
thread surfaces each computed their own view and disagreed in three ways
that were invisible from any single call site:

  limit       session_start 50 vs list_threads 100
  status      session_start excluded "dormant", list_threads did not
  ordering    the dedup loader had no ORDER BY at all

Those disagreements are the f02f74ca panel-vs-list defect and the
nondeterministic dedup candidate set. Both are closed here rather than in
three separate patches.

  resolveThreadScope()      (project, sessionId) from overrides > session > config
  buildScopedThreadQuery()  the one query shape: filters, order, limit
  isInScope()               same predicate for rows not from a scoped query
  THREAD_SCOPE_ORDER        total order — thread_id breaks ties, so the
                            candidate set is stable at the limit boundary

Dedup uses "own_session" visibility per R1: a match against another
session's thread is never a safe automatic merge. With no active session
the query is null, not project-wide — a sessionless caller must not
silently dedup against the whole namespace.

The resolver is downstream of session identity: it scopes correctly
relative to whatever (project, sessionId) it is handed. Deciding who the
caller is belongs to GIT-51. The project-disagreement fixture pins that
seam by name so the boundary is explicit rather than discovered.

Tests: +27 (parity, cross-project isolation, determinism, own-session
candidate set, status sets, recovery-seam fixture). Suite 1031 green,
typecheck clean. Contains zero lines from wip/git-51-session-recovery.

Consumer wiring (session_start / list_threads / resolve_thread / dedup)
follows in the next commit on this branch.

Refs GIT-69, GIT-63, GIT-67

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every surface that reads or resolves threads now imports one definition of
"the thread store, scoped". The three disagreements are gone:

  session_start  limit 50, excluded dormant, own query  → resolver
  list_threads   limit 100, included dormant, own query → resolver
  resolve_thread its own project lookup                 → resolver
  dedup          no ORDER BY, project-wide candidates   → resolver, own_session

Per R1, dedup candidates are the caller's own session only; with no active
session the query is null and dedup is skipped rather than widening to the
namespace. The local-file fallback is held to the same scope, so cache and
store cannot disagree.

session_close's suggestion pass opts into "project" visibility explicitly —
it proposes threads to a human rather than silently merging into one. That
is a parameter, not a hardcoded default, so the distinction stays visible.

Per R7, dormant is IN scope and filtered at display only, and the panel
cap is annotation rather than scope: computePanelOmission/formatOmissionLine
render "showing 5 of 47 in scope (31 dormant, 11 over cap) — list_threads
for full view" whenever display < scope. A panel showing 5 of 47 with no
indicator is a green-message pattern in miniature.

Tests: +11 display-layer (7 reconciliation cases asserting
shown + dormantHidden + overCap === totalInScope, plus disclosure and
zero-term cases). Suite 1042 green, typecheck and build clean.
Contains zero lines from wip/git-51-session-recovery.

Refs GIT-69, GIT-63, GIT-67

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cache-health reported healthy on scar parity alone while two threads were
demonstrably divergent (feedback ad5ca35a). "Healthy" asserted from one
covered store is the same shape as a write reporting success from an
unread row — a claim of completeness that was never checked.

diffThreads() supplies the thread half as a pure, testable diff.

Three buckets rather than two, because the stores are not partitioned the
same way: remote rows carry a project, the local threads.json does not
(ThreadObject has no project field, so one flat file holds every
project's threads). A naive in-local/in-remote diff for project A would
report every local project-B thread as drift. That is false drift, and a
detector that cries wolf trains its reader to ignore it — which is how
ad5ca35a's real divergence stayed invisible beside a healthy banner.

So local-only IDs are reported as explicitly unattributable rather than
folded into drift, and describeThreadDrift() always names the count even
though it is not drift: an omitted number reads as zero.

Tests: +13, including the equal-counts-but-divergent case a count-parity
check calls healthy, and the false-drift case.

Not yet wired into checkCacheHealth — one definitional question posted to
GIT-69 first (whether unattributable locals degrade status).

Refs GIT-69

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per R9a. cache-health reported healthy on scar parity alone while two
threads were divergent (ad5ca35a). Threads are now a covered store, and
"healthy" requires parity on every store checked.

Status is three-valued because binary was the false choice:

  healthy       full attribution AND no divergence — earned, never defaulted
  unverifiable  no known divergence, but the check did not complete
  drift         divergence is known

"Healthy" beside "12 threads I could not classify" is a completeness claim
over an incomplete check. "Drift" would be equally false — nothing is known
divergent. The middle state exists so neither lie is available. Same shape
as R4's local_only/durable:false, applied to reads instead of writes.

A failed remote thread read reports unavailable and downgrades the overall
verdict, rather than silently omitting the store it could not reach and
still printing healthy — that omission IS ad5ca35a.

checkCacheHealth's project parameter becomes live for the thread half and
stays ignored for the scar half, deliberately: the local scar cache is
unified cross-project, threads are project-scoped by schema. Documented at
the call site rather than papered over.

Tests: +18 total on thread-drift (5 new for three-valued status, including
drift-beats-unverifiable and never-healthy-with-a-non-empty-bucket).
Suite 1060 green across 64 files, typecheck clean.

Refs GIT-69, ad5ca35a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e (GIT-69 item 3)

Closes feedback 11c313d0 (non-atomic multi-record persistence). The
mechanism was sharper than "doesn't report a partial write":

    syncThreadsToSupabase(...).catch(err =>
      console.error("... (non-fatal):", err));   // outcome discarded
    saveThreadsFile(openThreadsOnly);            // prune runs regardless

A failed sync plus a successful prune dropped resolved threads locally
while remote still held them open — no error, green close. A destructive
local operation gated on a remote write whose result was never examined.

This is the purest instance of scar cd345431 (fire-and-forget hides
constraint violations) in the product: the swallow and the destruction sat
two lines apart, and the second depended on the first.

Changes:

- syncThreadsToSupabase returns ThreadSyncResult {attempted, synced,
  failed[], skipped, all_synced} instead of void. It still completes a
  partial sync — worth finishing — but now names every miss.
- session_close AWAITS it. Prune runs only when all_synced; otherwise
  threads.json is retained in full, because it is the only remaining
  record of the threads that did not land.
- Session files and the closing payload are retained on partial persist
  too. Reporting failure while deleting the means to retry would be R4's
  crime in reverse.
- Close returns success:false with validation_errors naming each missed
  thread id and its error, per R4's fail-honest shape.

R3 — the gate is tier-relative. Free tier has no Supabase to await, so
sync reports skipped:true and the prune proceeds on local persistence
alone. Awaiting a sync that does not exist would fail-close every free
user's session close.

Adjacency: with the sync now awaited, GIT-70's unordered limit-200 dedup
window runs synchronously inside close, so its duplicate-creation
behaviour is close-path-critical rather than background.

Tests: +8 covering the outcome contract the prune gate depends on —
tier skip, empty-list success, single failure recorded not swallowed,
partial split, never-all_synced-with-failures, every missed id named.
Suite 1068 green across 65 files, typecheck and build clean.

Refs GIT-69, GIT-70, 11c313d0, cd345431

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per R9c. Answers feedback c1b65b66 (orchestrator cannot resolve a child
session's thread) without inventing a lineage model gitmem does not have.

The asymmetry that justifies this, and does NOT justify it for dedup:
resolve is an explicit, human-directed act on a NAMED thread — the caller
states a specific target — while dedup is automatic and silent over a
candidate set it never sees. R1's own-session caution therefore applies to
dedup and not here.

Condition (1) — cross-project resolve refused. getThreadFromSupabaseById
filters on thread_id alone with NO project filter, so before this change a
caller in project A could resolve project B's thread outright. Ownership is
now checked BEFORE hydrating by ID, because hydrating first would resolve
across projects silently.

Condition (2) — audit trail as the price of the widened power. Any resolve
of a thread this session does not own names the owning session in both the
response and the log. Silence here is exactly what project scope would
otherwise have bought us. No line when the caller owns the thread: an
always-on notice is noise, and noise is how a real signal gets ignored.

Condition (3) — a miss returns ownership information where it exists
("exists in project P, owned by session Y, status S") rather than a bare
not-found. Reporting absence for a thread that demonstrably exists is the
same lie as reporting success for a write that did not land.

Condition (4) — parent_session filed as GIT-72 against GIT-51's session
model. SessionChild is NOT a session: it has no session_id, it records
roles inside one session. No lineage is synthesized from it here.

New getThreadOwnership() is deliberately unscoped — the one lookup allowed
to see across projects, because its purpose is to explain a refusal rather
than grant access. Documented as such so it is not later mistaken for a
widening primitive.

Tests: +4 (cross-project refusal names the project and writes nothing;
miss carries ownership; cross-session resolve names the owner; owned
resolve stays quiet). Suite 1072 green across 65 files, typecheck and
build clean.

Refs GIT-69, GIT-72, c1b65b66

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness for the sprint's P0, built first so the fix has an honest way
to be verified. A stdio MCP harness already existed (tests/e2e/mcp-client.ts,
booting local dist/ headless with env overrides) — this extends it rather
than adding a parallel one.

Added:
  createOutageEnv()   pro tier with SUPABASE_URL at a closed port. The
                      f9fa0461 condition reproduced honestly — healthy
                      session, dead SOT — through the real write path, no
                      mocking. Port 1 refuses instantly, so failures are
                      immediate rather than 30s timeouts.
  restartServer()     close and respawn preserving GITMEM_DIR/HOME. Files
                      persist, process does not — the state session-identity
                      loss actually occurs in.
  queryThreadRow()    direct REST read, bypassing every gitmem code path.
                      THROWS on an unreachable store rather than returning
                      null: "cannot reach" must never read as "row absent",
                      or the harness reproduces the fail-open bug it exists
                      to catch.
  hasLiveSupabase()   so row-level tests skip loudly instead of vacuously.

Repro (tests/e2e/git-67-fail-open.test.ts) — 4 of 5 assertions FAIL against
this build, which is the point. A repro that passes before the fix is not a
repro. Confirmed defect shape, captured from the live probe:

    Thread created: "..."
    ID: t-02972e09 · 1 open threads

on a pro tier whose durable store is unreachable. Bare success, minted ID,
no caveat, no error.

Two lessons baked into the file as comments:

1. An early assertion keyed on "Thread created" PASSED while the defect was
   live, because the response had taken the dedup shape instead. Assertions
   are now shape-independent: no ID may reach the caller without a statement
   about durability, whatever phrasing wraps it.

2. An early assertion for "admits non-durability" PASSED because the test's
   own thread text contained the word "durable" and the tool echoes
   submitted text back. Render-from-submission corrupted the test written to
   catch render-from-submission. All thread text is now sentinel-free.

The surviving passing assertion is documented as weak — it is satisfied by
the enforcement banner alone while an ID is minted beside it.

No source changes. Fix follows.

Refs GIT-67, GIT-63, GIT-45, f9fa0461

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sprint's P0. Community feedback f9fa0461: create_thread returned a
plausible t-xxxxxxxx for a row that never existed. Verified fixed against
the harness from 436c7ff, which failed 4 of 5 assertions before this
commit and passes 11 of 11 after.

R5 — no active session, tier-relative. Pro refuses outright with
stored:false, reason:"no_active_session", and NO id. An invented ID is
strictly worse than an error: the caller records it and moves on. Free
tier still writes, because there the local file IS the SOT and sessionless
use is legitimate — refusing would fail-close every free user. Its
response says "session: none" rather than implying an ownership it lacks.

R2 — dedup refuses; it neither merges nor discards. The old path returned
the matched thread's ID with the SUBMITTED text rendered beside it and
success:true, while the stored row kept its own text and only updated_at
moved. Now: stored:false, reason:"duplicate_candidate", the matched row's
CURRENT text read back from the store, and both lengths so a discard is
arithmetically visible. Two explicit ways through — resolve-then-create,
or allow_duplicate:true. Critically there is no `thread` field on this
response: returning a ThreadObject is exactly how the old code handed back
another thread's ID as though it were the caller's.

R4 — fail-honest on store failure. A Supabase write that does not land no
longer falls through to a success with a minted ID. The local buffer
survives (destroying data to signal an error repeats the crime in
reverse), but the response is unmistakably non-success:
stored_in:"local_only", durable:false, supabase_error, and prose saying it
will not reach another session.

R3 — every response names its store (stored_in: supabase | local |
local_only), which also permanently closes the f104e10d silent-local trap:
a write that quietly went local can no longer look identical to one that
reached Supabase.

The convention underneath all four: the response is rendered from a
post-write READ of the stored row, never from the submitted payload. An ID
appears in output only if the row backing it exists. Where neither store
has the row, nothing is claimed at all.

Tests: +6 e2e (dedup refusal states not-stored, shows both lengths, offers
a way through, allow_duplicate works; free tier stores and names
session:none). Suite 1072 unit + 11 e2e green, typecheck and build clean.

One spec tension flagged to GIT-67, not resolved here: on free tier with
no session the enforcement banner and a success payload still coexist,
which GIT-67 criterion 3 words as "never both". They no longer contradict
— the payload says session:none and the banner says the same — but the
literal criterion is unmet and the wording needs a ruling.

Refs GIT-67, GIT-63, f9fa0461, e39ec941

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Criterion 3 originally read "a response must never contain both a success
payload and an enforcement failure banner." That was written against the
old defect, where the two CONTRADICTED — success claimed while enforcement
asserted the write's precondition was absent. The contradiction is what
made the response a lie, not the co-presence.

R10 amends it: a response must never simultaneously claim success and
assert a condition that negates that success; banner and payload must
agree on session state and storage state.

This test encodes the amendment so it is mechanical rather than
remembered. It asserts CONSISTENCY, not absence:

  - when the banner denies a session AND the payload reports a stored
    thread, the payload must also say session:none and name its store —
    either omission recreates the lie
  - a payload may never both present an id and assert the write did not
    store, in any tier

Closes the coverage gap disclosed in c0b577e: the dual-message test was
pro-only, so a free-tier violation would have passed unnoticed.

e2e 12/12 green.

Refs GIT-67

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nTEG-dev
nTEG-dev marked this pull request as ready for review August 7, 2026 02:23
@nTEG-dev
nTEG-dev merged commit 9bc2cb8 into main Aug 7, 2026
4 checks passed
@nTEG-dev
nTEG-dev deleted the feature/GIT-69-thread-scope-resolver branch September 23, 2026 05:49
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