Public: anyone can read this; the crew can change it.

The sentinel stops refiling a setting a person kept on purpose; it reports it again only when the value changes #2006

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2001-the-sentinel-stops-refiling-a-setting-a → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/2001-the-sentinel-stops-refiling-a-setting-a was merged into main as 136ce16.

Review

Sign in to review this change.
4 files changed +138 −4
README.md +9
@@ -619,6 +619,15 @@ default is new. Each finding is one issue with its evidence, filed in
619619 the first admin. Evidence is scrubbed of anything credential-shaped before it
620620 is filed.
621621
622 A setting kept on purpose is a decision, not drift. Closing a `setting:<key>`
623 finding's issue, or commenting on it (a person, not the filer and not an agent), records the value
624 and its `updated_at` in `sentinel_kept`; while both match, the sentinel files
625 nothing and comments nothing for that key. Changing the value on the capacity
626 page ends the keeping, and the setting is reported again under the usual
627 one-day rule. The rule is mechanical: any close counts, with or without a
628 reason, so a bare close silences the setting until its value changes. Other
629 findings (`held`, `ledger`, `red`) are filed anew when they return.
630
622631 - **Board:** a site admin's forge strip shows the open findings and the last
623632 hunt; a guest or member sees nothing.
624633 - **Terminal:** press `ctrl+k`, run **Sentinel** — the same line as a toast.
src/core/migrations/20261016090000-sentinel-kept-settings.ts added +11
@@ -0,0 +1,11 @@
1 // A setting a person kept on purpose (#2001): the value and its
2 // `forge_setting.updated_at` when its finding was closed or explained, so the
3 // sentinel reports it again only when the value changes.
4 export default `
5 CREATE TABLE IF NOT EXISTS sentinel_kept (
6 key TEXT PRIMARY KEY,
7 value TEXT NOT NULL,
8 updated_at TEXT NOT NULL,
9 kept_at TEXT NOT NULL
10 );
11 `;
src/crew/sentinel.ts +42 −3
@@ -62,6 +62,8 @@ export interface Finding {
6262 readonly urgent?: true;
6363 /** What a person or a run would do, when it is mechanical and reversible. */
6464 readonly fix?: string;
65 /** A setting finding: the value and `updated_at` a person may have kept (#2001). */
66 readonly kept?: { readonly value: string; readonly updatedAt: string };
6567 }
6668
6769 const clip = (text: string, n = 200): string =>
@@ -186,7 +188,8 @@ export function settingsAway(db: Database, now: number): Finding[] {
186188 `set by ${row.username ?? "unknown"} at ${row.updated_at}`,
187189 ],
188190 pillar: "Trust: a setting is a decision, not a leftover",
189 lookFor: `${key} is back at "${capacityDefault(key)}", or someone says in a comment why it stays.`,
191 lookFor: `${key} is back at "${capacityDefault(key)}", or someone says why it stays: close this issue with a reason (or comment one; a bare close keeps it too) and the setting is kept, reported again only if its value changes.`,
192 kept: { value, updatedAt: row.updated_at },
190193 fix: `Put it back: clear ${key} on the capacity page (it is recorded and reversible).`,
191194 });
192195 }
@@ -545,10 +548,45 @@ function handOver(app: App, repo: Repo, actor: User, issue: Issue): void {
545548 }
546549 }
547550
551 /** The finding is a setting whose kept value and `updated_at` are on record. */
552 function keptRecorded(db: Database, f: Finding): boolean {
553 if (f.kept === undefined) return false;
554 const kept = db
555 .query<{ value: string; updated_at: string }, [string]>("SELECT value, updated_at FROM sentinel_kept WHERE key = ?1")
556 .get(f.key);
557 return kept !== null && kept.value === f.kept.value && kept.updated_at === f.kept.updatedAt;
558 }
559
560 /**
561 * Whether a person decided to keep this setting as it now stands: its
562 * finding was closed, or explained in a comment by a person (not the sentinel, not an agent),
563 * after the value was last set. A decision is recorded with the value and
564 * `updated_at` it was made at; the same pair is kept, any other is drift.
565 */
566 function settingKept(db: Database, f: Finding, actorId: string, at: string): boolean {
567 if (f.kept === undefined) return false;
568 if (keptRecorded(db, f)) return true;
569 const { value, updatedAt } = f.kept;
570 const decided = db
571 .query<{ n: number }, [string, string, string]>(
572 `SELECT COUNT(*) AS n FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
573 WHERE t.key = ?1 AND ((i.state = 'closed' AND i.closed_at >= ?2)
574 OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.author_id <> ?3 AND c.created_at >= ?2
outdated

c.author_id <> actor accepts any other author, including agents. Restrict to human users so a bot comment does not silently keep a setting.

575 AND EXISTS (SELECT 1 FROM user u WHERE u.id = c.author_id AND u.is_agent = 0 AND u.kind = 'user')))`,
576 )
577 .get(f.key, updatedAt, actorId);
578 if (decided === null || decided.n === 0) return false;
outdated

Any close at or after updated_at counts as kept, reason or not. Fine as a rule, but document it in the README.

579 db.query(
580 "INSERT INTO sentinel_kept (key, value, updated_at, kept_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(key) DO UPDATE SET value = ?2, updated_at = ?3, kept_at = ?4",
581 ).run(f.key, value, updatedAt, at);
582 return true;
583 }
584
548585 /**
549586 * Read, file, record. One open issue per finding: a tracked finding whose
550587 * issue is open is commented on only when its evidence changed; one whose
551 * issue is closed and returns is a new issue.
588 * issue is closed and returns is a new issue — except a setting a person
589 * kept (`settingKept`), which stays quiet until its value changes.
552590 */
553591 export function hunt(
554592 app: App,
@@ -565,6 +603,7 @@ export function hunt(
565603 if (repo !== undefined && actor !== undefined) {
566604 for (const f of findings) {
567605 try {
606 if (settingKept(app.db, f, actor.id, at)) continue;
568607 const evidence = f.evidence.join("\n");
569608 const tracked = app.db
570609 .query<
@@ -698,7 +737,7 @@ export function sentinelTick(app: App, now = Date.now()): HuntResult | null {
698737 .all()
699738 .map((r) => r.key),
700739 );
701 if (!signals.some((s) => !tracked.has(s.key))) return null;
740 if (!signals.some((s) => !tracked.has(s.key) && !keptRecorded(app.db, s))) return null;
702741 }
703742 return hunt(app, repoPath, now);
704743 }
test/sentinel.test.ts +76 −1
@@ -13,7 +13,7 @@ import { createRepo } from "../src/actions/repos.ts";
1313 import { setCapacity } from "../src/core/capacity.ts";
1414 import { clearErrors, createLog } from "../src/kernel/log.ts";
1515 import { recordDecision } from "../src/core/decisions.ts";
16 import { createLabel, labelsFor, listComments, listIssues } from "../src/core/issues.ts";
16 import { addComment, createLabel, labelsFor, listComments, listIssues } from "../src/core/issues.ts";
1717 import { addCrew, createAgentRow } from "../src/core/agents.ts";
1818 import { listEvents } from "../src/core/events.ts";
1919 import { getRepoById, type Repo } from "../src/core/repos.ts";
@@ -41,6 +41,7 @@ let repo: Repo;
4141 const NOW = Date.parse("2026-10-13T12:00:00.000Z");
4242 const ago = (ms: number): string => new Date(NOW - ms).toISOString();
4343 const HOUR = 3_600_000;
44 const DAY = 24 * HOUR;
4445
4546 function plantRun(
4647 kind: "check" | "agent",
@@ -385,6 +386,80 @@ describe("the signals inside the hour", () => {
385386 expect(withRepo(() => sentinelTick(app, lastHunt() + 20 * 60_000))).toBeNull();
386387 });
387388
389 test("a setting kept on purpose is not refiled until its value changes (#2001)", () => {
390 const setAt = (value: string, at: string): void => {
391 setCapacity(app.db, "checks.concurrency", value, alice.id);
392 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(at);
393 };
394 const issuesFor = (): number =>
395 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Sentinel: Setting checks.concurrency %'").get()!.n;
396 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
397 app.db.query("DELETE FROM sentinel_kept").run();
398 app.db.query("DELETE FROM forge_setting WHERE key IN ('cells.egress', 'checks.concurrency')").run();
399 const base = Date.now();
400 setAt("4", new Date(base - 10 * 24 * HOUR).toISOString());
401 expect(hunt(app, "alice/forge", base).filed).toBe(1);
402 const first = app.db.query<{ id: string; body: string }, []>("SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
403 expect(first.body).toContain("close this issue with a reason");
404 // closed with a reason: kept
405 app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(first.id, new Date(base).toISOString());
406 const comments = listComments(app.db, first.id).length;
407 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0);
408 expect(hunt(app, "alice/forge", base + 4 * HOUR).filed).toBe(0);
409 expect(issuesFor()).toBe(1);
410 expect(listComments(app.db, first.id).length).toBe(comments);
411 // the value changes, and later is away a day: reported again
412 setAt("6", new Date(base + 3 * HOUR).toISOString());
413 expect(hunt(app, "alice/forge", base + 6 * HOUR).filed).toBe(0); // not yet a day away
414 expect(hunt(app, "alice/forge", base + 30 * HOUR).filed).toBe(1);
415 expect(issuesFor()).toBe(2);
416 expect(app.db.query("SELECT value FROM forge_setting WHERE key = 'checks.concurrency'").get()).toEqual({ value: "6" });
417 app.db.query("DELETE FROM forge_setting WHERE key = 'checks.concurrency'").run();
418 });
419
420 test("a comment saying why a setting stays keeps it too (#2001)", async () => {
421 const owner = await createUser(app.db, {
422 trusted: true,
423 username: "carol",
424 email: "[email protected]",
425 password: "a-good-passphrase",
426 });
427 if (!owner.ok) throw new Error(owner.error);
428 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
429 app.db.query("DELETE FROM sentinel_kept").run();
430 const base = Date.now();
431 setCapacity(app.db, "checks.concurrency", "4", alice.id);
432 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(new Date(base - 10 * 24 * HOUR).toISOString());
433 hunt(app, "alice/forge", base);
434 const issue = app.db.query<{ id: string; body: string }, []>("SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
435 const said = addComment(app.db, { issueId: issue.id, authorId: owner.value.id, body: "Kept on purpose: P0 wants it." });
436 expect(said.ok).toBe(true);
437 app.db.query("UPDATE comment SET created_at = ?2 WHERE issue_id = ?1").run(issue.id, new Date(base + 1000).toISOString());
438 const before = listComments(app.db, issue.id).length;
439 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0);
440 expect(listComments(app.db, issue.id).length).toBe(before);
441 expect(app.db.query("SELECT value FROM sentinel_kept WHERE key = 'setting:checks.concurrency'").get()).toEqual({ value: "4" });
442 setCapacity(app.db, "checks.concurrency", "1", alice.id);
443 });
444
445 test("a comment from an agent does not keep a setting (#2001)", async () => {
446 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
447 app.db.query("DELETE FROM sentinel_kept").run();
448 const base = Date.now();
449 setCapacity(app.db, "checks.concurrency", "4", alice.id);
450 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(new Date(base - 10 * 24 * HOUR).toISOString());
451 hunt(app, "alice/forge", base);
452 const issue = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
453 const bot = await createUser(app.db, { trusted: true, username: "reviewbot", email: "[email protected]", password: "a-good-passphrase" });
454 if (!bot.ok) throw new Error(bot.error);
455 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.value.id);
456 addComment(app.db, { issueId: issue.id, authorId: bot.value.id, body: "Looks fine to me." });
457 app.db.query("UPDATE comment SET created_at = ?2 WHERE issue_id = ?1").run(issue.id, new Date(base + 1000).toISOString());
458 hunt(app, "alice/forge", base + 2 * HOUR);
459 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
460 setCapacity(app.db, "checks.concurrency", "1", alice.id);
461 });
462
388463 test("a newly held slot hunts too", () => {
389464 const run = createRun(app.db, { repoId: repo.id, kind: "deploy" });
390465 const l = lastHunt();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs