daemon: matcher fixback — B1/B2/f4/P3 (two-tier doctrine)

Blocker-only repair on frozen candidate 302036aa (guard B1/B2/B3, arch
fixback plan v1.4 f1-f6).

B1 (hasActiveQitem): drop the qitemIds leg-2 membership check — it inherited
matchQitems' sanctioned legacy substring/body fallback, so a zero-typed
body-mention-only row promoted activeQitemPresent to true (phase BUILD) while
the strict band was empty. Leg 1 streams (iterate + break on first canonical
confirm, NO SQL LIMIT — a pre-confirmation LIMIT would recreate B2 at the
phase signal). Equivalence: every canonical member contains the literal
slice:<name> substring (P1), so leg 1's untruncated prefilter finds them all;
leg 2 could only add non-canonical display-tier ids.

B2 (matchQitems typed tier): cap on CONFIRMED matches, not the prefilter
window. iterate() ORDER BY ts_created DESC, qitem_id DESC (P2 tiebreak =
deterministic), confirm per row, break at 500 confirmed; NO prefilter LIMIT
(else >500 suffix decoys hide a true typed member and un-gate the fallback).
Substring tier + its LIMIT 500 + terms untouched.

f4: delete the dead, untested excludeTagPrefix === "mission:" branch (only
caller passes "slice:").

P3: the two-tier doctrine ships by name in the helper JSDoc and both consumer
comments — SIGNAL tier (phase/band/attention) = canonical-only; DISPLAY tier
(queue-tab qitemIds) = gated legacy fallback; never promote display into signal.

Tests: §3.9 (zero-typed body-only: display-tier keeps it, signal-tier drops it,
phase/band agree), §3.10 (suffix-storm: true member kept, fallback gated,
hasActiveQitem true), §3.10a (P2 tie-vector: deterministic id-DESC order).
Daemon-only; UI untouched; agentsForSlices/composeAgents band code unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
mvschwarzandClaude Fable 5 committed 2026-07-11 00:41:32 -07:00
1 parent 302036aa6c
commit cab26bb0a0
4 files changed
+134 -36

No files matched your search

+27 -27
View File
@@ -236,7 +236,7 @@ export class ReviewGatherer {
attention,
agents,
workflows: this.gatherWorkflowExceptions(this.now()),
activeQitemPresent: this.hasActiveQitem(name, slice),
activeQitemPresent: this.hasActiveQitem(name),
git: this.gatherGitFacts(frontmatter, candidateRef),
approval,
nowIso: this.now(),
@@ -511,7 +511,6 @@ export class ReviewGatherer {
// intent — "rows that carry a slice tag") so comma-legacy rows are
// excluded like clean ones; unknown prefixes keep raw semantics.
if (excludeTagPrefix === "slice:") return scopes.slices.size === 0;
if (excludeTagPrefix === "mission:") return scopes.missions.size === 0;
if (excludeTagPrefix) {
try {
const tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? [];
@@ -821,34 +820,35 @@ export class ReviewGatherer {
}
}
private hasActiveQitem(name: string, slice: SliceRecord): boolean {
private hasActiveQitem(name: string): boolean {
if (!this.tableExists("queue_items")) return false;
// Leg 1 (canonical): unquoted prefilter now catches comma-legacy rows;
// each candidate is confirmed against the shared membership predicate,
// short-circuiting on the first true. Post-fix, leg 2 (qitemIds, which
// inherits matchQitems) converges on the same answer.
const prefiltered = this.db
.prepare(
`SELECT tags FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ?`,
)
.all(...ACTIVE_STATES, `%slice:${name}%`) as Array<{ tags: string | null }>;
for (const r of prefiltered) {
// TWO-TIER DOCTRINE: this is a SIGNAL-tier answer (phase / band /
// attention) — it answers from canonical membership ONLY. The DISPLAY
// tier (the queue-tab's qitemIds) may carry the gated legacy substring
// fallback; never promote a display-tier match into a signal. (P3)
//
// B1 fix: the qitemIds (leg 2) membership check is DROPPED. It inherited
// matchQitems' sanctioned legacy substring/body fallback, so in a
// zero-typed corpus a body-mention-only row promoted activeQitemPresent
// to true (phase BUILD) while the strict band was empty — the exact
// one-compose-two-answers divergence this slice kills. Equivalence: any
// canonically-confirmable row necessarily contains the literal
// `slice:<name>` substring (P1 construction), so leg 1's UNTRUNCATED
// prefilter finds every canonical member; leg 2 could only ever ADD
// non-canonical (display-tier) ids — precisely the class that must not
// reach the phase signal.
//
// Leg 1 streams: iterate() + break on the FIRST canonical confirm. NO SQL
// LIMIT — a pre-confirmation LIMIT would recreate B2 here (a suffix-storm
// could hide the true member and flip the phase signal); streaming + break
// gives the same bounded cost with no truncation hole.
const stmt = this.db.prepare(
`SELECT tags FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ?`,
);
for (const r of stmt.iterate(...ACTIVE_STATES, `%slice:${name}%`) as Iterable<{ tags: string | null }>) {
if (parseScopeTags(r.tags).slices.has(name)) return true;
}
if (slice.qitemIds.length === 0) return false;
const placeholders = slice.qitemIds.map(() => "?").join(",");
try {
return this.db
.prepare(
`SELECT 1 FROM queue_items
WHERE qitem_id IN (${placeholders})
AND state IN (${ACTIVE_STATES.map(() => "?").join(",")})
LIMIT 1`,
)
.get(...slice.qitemIds, ...ACTIVE_STATES) !== undefined;
} catch {
return false;
}
return false;
}
/** §4 — the two staged-approval stamps (arch F-A: the SHIPPED verb's
@@ -17,7 +17,12 @@ export interface QitemScopeTags {
* comma-split (the legacy CLI form, per the matchQitems contract comment)
* and trimmed; exact `slice:`/`mission:` prefix match per token. No
* substring semantics live here — the legacy substring tier remains a
* matchQitems-local concern behind its zero-typed-rows condition. */
* matchQitems-local concern behind its zero-typed-rows condition.
*
* TWO-TIER DOCTRINE: the SIGNAL tier (phase / band / attention) answers
* from canonical membership ONLY — this function. The DISPLAY tier (the
* queue-tab's qitemIds) may carry the gated legacy substring fallback.
* Never promote a display-tier match into a signal. */
export function parseScopeTags(rawTags: string | null | undefined): QitemScopeTags {
const slices = new Set<string>();
const missions = new Set<string>();
@@ -451,18 +451,33 @@ export class SliceIndexer {
// form (legacy CLI tags) are caught by a wildcard on the slice id.
let typedTagMatchCount = 0;
try {
const rows = this.db.prepare(
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE ? LIMIT 500`,
).all(`%slice:${sliceName}%`) as Array<{ qitem_id: string; tags: string | null }>;
// The unquoted LIKE stays a PREFILTER (catches comma-legacy AND the
// TWO-TIER DOCTRINE: the SIGNAL tier (phase / band / attention)
// answers from canonical membership ONLY; this DISPLAY tier (the
// queue-tab's qitemIds) may carry the gated legacy substring fallback
// below. Never promote a display-tier match into a signal. (P3)
//
// B2 fix: cap on CONFIRMED matches, never the prefilter window. The
// unquoted LIKE stays a PREFILTER (catches comma-legacy AND the
// suffix/sibling over-matches); parseScopeTags is the authoritative
// row-level confirm. typedTagMatchCount counts CONFIRMED rows only,
// so the substring gate below reflects real typed membership — this
// kills the VM-004 `slice:X-suffix` over-match at the typed tier.
for (const r of rows) {
// row-level confirm. We iterate() ORDER BY ts_created DESC, qitem_id
// DESC (P2: the id tiebreak makes the confirmed set fully deterministic
// under equal timestamps — byte-consistent with the id-lex tail
// fallback below, since the id encodes the ts prefix), confirm per
// row, and break at 500 CONFIRMED. There is NO pre-confirmation SQL
// LIMIT: with one, >500 suffix/sibling over-match rows could hide a
// true typed member and un-gate the fallback (VM-004 returns).
// Streaming keeps memory bounded, and the leading-wildcard LIKE never
// used an index anyway (the old LIMIT saved materialization only,
// which iterate+break also saves). typedTagMatchCount counts CONFIRMED
// rows only, so the substring gate reflects real typed membership.
const stmt = this.db.prepare(
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE ? ORDER BY ts_created DESC, qitem_id DESC`,
);
for (const r of stmt.iterate(`%slice:${sliceName}%`) as Iterable<{ qitem_id: string; tags: string | null }>) {
if (parseScopeTags(r.tags).slices.has(sliceName)) {
ids.add(r.qitem_id);
typedTagMatchCount++;
if (typedTagMatchCount >= 500) break;
}
}
} catch {
@@ -223,3 +223,81 @@ describe("scope-membership byte-identity carve (clean corpus, zero regression)",
expect(band?.provenance).toBe(`no agents holding or recently holding work — computed from queue at ${NOW}`);
});
});
// v1.4 fixback regressions — the two-tier doctrine made observable.
describe("scope-membership B1/B2/P2 blocker regressions (fixback)", () => {
let missionsRoot: string;
let cleanup: string;
let db: Database.Database;
beforeEach(() => {
cleanup = fs.mkdtempSync(path.join(os.tmpdir(), "scope-fixback-"));
missionsRoot = path.join(cleanup, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
for (const s of ["target", "sibling", "legacyonly", "tie-slice"]) {
writeSlice(missionsRoot, path.join("relx", "slices"), s);
}
db = createDb(":memory:");
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema, queueItemSummarySchema]);
});
afterEach(() => {
db.close();
fs.rmSync(cleanup, { recursive: true, force: true });
});
function harness() {
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, additionalSliceRoots: [], dogfoodEvidenceRoot: null, db });
const gatherer = new ReviewGatherer({ db, indexer, gitRepoPath: null, now: () => NOW });
return { indexer, gatherer };
}
it("§3.9 B1: zero-typed body-only row shows in the DISPLAY tier but NOT the SIGNAL tier (phase/band agree)", () => {
// legacyonly has zero typed slice: rows; one ACTIVE body-mention-only qitem.
insertQitem(db, { id: "q-body-legacy", dest: "leg@rig", tags: [], body: "advance the legacyonly rollout" });
const { indexer, gatherer } = harness();
// DISPLAY tier (queue-tab qitemIds): the legacy substring fallback keeps it.
const ids = new Set(indexer.get("legacyonly")?.qitemIds ?? []);
expect(ids.has("q-body-legacy")).toBe(true);
// SIGNAL tier: hasActiveQitem FALSE (leg 2 dropped) AND band empty -> AGREE.
const active = (gatherer as unknown as { hasActiveQitem(n: string): boolean }).hasActiveQitem("legacyonly");
const band = gatherer.composeAgents("slice:legacyonly");
expect(active).toBe(false); // was TRUE at 302036aa via the dropped leg-2 -> phase BUILD divergence
expect(band?.rows).toEqual([]);
});
it("§3.10 B2 suffix-storm: >500 slice:target-extra decoys ahead of the true row — true member kept, fallback gated, phase TRUE", () => {
// Decoys FIRST (they'd fill a pre-confirmation LIMIT-500 window and hide the true row at 302036aa).
const seedDecoys = db.transaction((n: number) => {
for (let i = 0; i < n; i++) {
insertQitem(db, { id: `q-decoy-${String(i).padStart(3, "0")}`, dest: "decoy@rig", tags: ["slice:target-extra"] });
}
});
seedDecoys(550);
insertQitem(db, { id: "q-true-target", dest: "truebuilder@rig", tags: ["slice:target"] });
insertQitem(db, { id: "q-body-target", dest: "bodyer@rig", tags: [], body: "notes about target here" });
const { indexer, gatherer } = harness();
const ids = new Set(indexer.get("target")?.qitemIds ?? []);
expect(ids.has("q-true-target")).toBe(true); // retained despite the storm (no truncation before confirm)
expect(ids.has("q-body-target")).toBe(false); // typed confirmed >=1 -> substring fallback GATED off
expect(ids.has("q-decoy-000")).toBe(false); // suffix over-match rejected by canonical confirm
// SIGNAL tier immune to truncation: hasActiveQitem TRUE, band shows the true seat only.
const active = (gatherer as unknown as { hasActiveQitem(n: string): boolean }).hasActiveQitem("target");
expect(active).toBe(true);
const sessions = new Set((gatherer.composeAgents("slice:target")?.rows ?? []).map((r) => r.sessionName));
expect(sessions.has("truebuilder@rig")).toBe(true);
expect(sessions.has("decoy@rig")).toBe(false);
expect(sessions.has("bodyer@rig")).toBe(false);
});
it("§3.10a P2 tie-vector: two confirmable rows sharing one ts_created — deterministic id-DESC order across re-runs", () => {
insertQitem(db, { id: "qtie-a", dest: "a@rig", tags: ["slice:tie-slice"] });
insertQitem(db, { id: "qtie-b", dest: "b@rig", tags: ["slice:tie-slice"] }); // same ts_created (NOW)
const { indexer } = harness();
const mq = indexer as unknown as { matchQitems(s: string, r: string | null, m: string | null): string[] };
const first = mq.matchQitems("tie-slice", null, "relx");
const second = mq.matchQitems("tie-slice", null, "relx");
expect(first).toEqual(second); // deterministic under equal timestamps
expect(first).toEqual(["qtie-b", "qtie-a"]); // qitem_id DESC tiebreak (P2)
});
});