@bongos/core 1.19.874 → 1.19.876

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.bongos-core.json +60 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +18 -0
  8. package/docs/api/openapi.json +218 -3
  9. package/docs/api-reference.md +6 -2
  10. package/docs/copy-inventory.md +35 -26
  11. package/docs/copy-registry.json +108 -27
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/autonomy/db.js +74 -0
  14. package/modules/autonomy/fence.js +172 -0
  15. package/modules/autonomy/migrations/autonomy_001_autobongos_fence.sql +71 -0
  16. package/modules/autonomy/routes/autonomy.js +91 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  18. package/modules/government/catalog.js +11 -0
  19. package/modules/government/migrations/government_016_autonomy_fence_manage.sql +37 -0
  20. package/modules/hall-ui/public/gate.html +14 -1
  21. package/modules/hall-ui/public/gate.js +117 -1
  22. package/modules/security/tests/artifact_scan.mjs +32 -9
  23. package/modules/security/tests/mirror.mjs +26 -4
  24. package/modules/security/tests/sanitize_rebuild.mjs +25 -4
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/autobongos-run.js +90 -15
  28. package/scripts/gds/run-unit-tests.js +30 -4
  29. package/src/module-api.js +1 -1
  30. package/tests/autobongos_fence.mjs +262 -0
  31. package/tests/autobongos_loop.mjs +120 -1
  32. package/tests/skip_is_not_pass.mjs +121 -0
  33. package/tests/tool_scan_engine.mjs +25 -4
  34. package/tests/unit_runner_teardown_abort.mjs +37 -0
@@ -44,7 +44,81 @@ async function lastAutonomyHead(routine) {
44
44
  return rows[0]?.head_sha || null;
45
45
  }
46
46
 
47
+ // ── the Autobongos fence (task 1003908, ADR 0307 §6) ─────────────────────────
48
+ //
49
+ // These back the one control surface the runner host cannot write to. Everything
50
+ // here is deliberately dumb — a SELECT and three small writes — because the
51
+ // decision logic is pure and lives in ../fence.js; a query that made a judgement
52
+ // would put half the fence somewhere no test can reach without a database.
53
+
54
+ // readFence — the kill switch and the allowlist, in one round trip.
55
+ //
56
+ // The singleton row is SEEDED by the migration, so a missing row means the
57
+ // migration has not run, not "not configured yet". Returning null for that makes
58
+ // it unreadable to the caller, which refuses — never a permissive default.
59
+ async function readFence() {
60
+ const [fence, goals] = await Promise.all([
61
+ pool.query('SELECT enabled, paused_reason, updated_at, updated_by FROM autonomy_fence WHERE id = 1'),
62
+ pool.query('SELECT goal_id, note, added_at, added_by FROM autonomy_allowed_goals ORDER BY added_at DESC'),
63
+ ]);
64
+ const row = fence.rows[0];
65
+ if (!row) return null;
66
+ return {
67
+ enabled: row.enabled === true,
68
+ paused_reason: row.paused_reason,
69
+ updated_at: row.updated_at,
70
+ updated_by: row.updated_by ? String(row.updated_by) : null,
71
+ // goal_id is a bigint, which node-postgres hands back as a STRING. The runner
72
+ // compares it against a task's goal_id, so normalise here rather than leaving
73
+ // every caller to remember — a '1000119' !== 1000119 mismatch would silently
74
+ // empty the allowlist and read as "nothing to work on".
75
+ goals: goals.rows.map((g) => ({
76
+ goal_id: Number(g.goal_id), note: g.note, added_at: g.added_at,
77
+ added_by: g.added_by ? String(g.added_by) : null,
78
+ })),
79
+ };
80
+ }
81
+
82
+ // setFenceEnabled — throw the kill switch. The reason is stored on the OFF case
83
+ // so a stopped machine can say who stopped it and why, which "halted" cannot.
84
+ async function setFenceEnabled({ enabled, reason = null, builderId = null }) {
85
+ const { rows } = await pool.query(
86
+ `UPDATE autonomy_fence
87
+ SET enabled = $1, paused_reason = $2, updated_at = now(), updated_by = $3
88
+ WHERE id = 1
89
+ RETURNING enabled, paused_reason, updated_at`,
90
+ [enabled === true, enabled === true ? null : (reason || 'stopped by an owner'), builderId]
91
+ );
92
+ return rows[0] || null;
93
+ }
94
+
95
+ // allowGoal — put a goal on the allowlist. Idempotent: re-adding an allowed goal
96
+ // updates its note rather than erroring, so the hall's form has no special case.
97
+ async function allowGoal({ goalId, note = null, builderId = null }) {
98
+ const { rows } = await pool.query(
99
+ `INSERT INTO autonomy_allowed_goals (goal_id, note, added_by)
100
+ VALUES ($1, $2, $3)
101
+ ON CONFLICT (goal_id) DO UPDATE SET note = EXCLUDED.note
102
+ RETURNING goal_id, note, added_at`,
103
+ [goalId, note, builderId]
104
+ );
105
+ const r = rows[0];
106
+ return r ? { ...r, goal_id: Number(r.goal_id) } : null;
107
+ }
108
+
109
+ // disallowGoal — take a goal off the allowlist. `removed` says whether a row
110
+ // actually went, because "already gone" and "just revoked" are different answers
111
+ // to an owner clicking Remove.
112
+ async function disallowGoal(goalId) {
113
+ const { rowCount } = await pool.query('DELETE FROM autonomy_allowed_goals WHERE goal_id = $1', [goalId]);
114
+ return { removed: rowCount > 0 };
115
+ }
116
+
47
117
  module.exports = {
48
118
  recordAutonomyRun,
49
119
  lastAutonomyHead,
120
+ readFence,
121
+ setFenceEnabled,
122
+ allowGoal,
123
+ disallowGoal,
50
124
  };
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/fence.js — what an unattended runner is allowed to touch
4
+ // (task 1003908, ADR 0307 §6). Pure: no I/O, no clock, no pool. The caller reads
5
+ // the facts and hands them in; this decides.
6
+ //
7
+ // FOUR GATES, and they are four because each fails in a different direction.
8
+ //
9
+ // 1. THE KILL SWITCH. A boolean in the database, flipped from the hall. Not a
10
+ // file and not an env var on the runner host: the premise of this whole
11
+ // system is a machine nobody is watching, running a worker with blanket
12
+ // bypassPermissions. Anything that machine can WRITE, the worker can write
13
+ // too, and a stop button the thing being stopped can press is not a stop
14
+ // button. So the switch lives where the machine can only read it.
15
+ //
16
+ // 2. THE ALLOWLIST. Which goals a worker may be spawned against, also in the
17
+ // database. `--goal` / AUTOBONGOS_GOALS survive as a NARROWING filter for an
18
+ // operator running one goal by hand, and they can only ever take goals AWAY
19
+ // from the allowlist — a machine-local setting must not be able to add one.
20
+ //
21
+ // 3. PROTECTED PATHS. The ADR 0043 registry, reused rather than re-derived.
22
+ // This is the honest half of the gate and the header says so: it reads the
23
+ // task's DECLARED `touches[]`, which is a claim by whoever filed the task,
24
+ // not a fact about what the work will edit. A task with `touches: []` clears
25
+ // it trivially. That is acceptable ONLY because it is the outer of three
26
+ // layers — the grader's permission-path pre-pass and the post-push main
27
+ // audit both read the REAL diff, server-side, where the machine cannot
28
+ // reach. Treating this layer as sufficient on its own would be the bug.
29
+ //
30
+ // 4. THE GRADER. The runner holds no push credential (ADR 0055), so the only
31
+ // road to main is the server-mediated ship and its server-side grade — which
32
+ // makes that grade the ONLY review standing between an unattended worker and
33
+ // main. When it is bypassed, the correct response is to STOP, not to ship
34
+ // unreviewed. This is the one gate whose failure mode is "everything still
35
+ // works, and nothing is being checked", which is why it is explicit.
36
+ //
37
+ // UNREADABLE IS NOT PERMITTED. Every gate fails CLOSED: a fence row that cannot
38
+ // be read, a malformed allowlist, an unknown grader state all refuse. This is the
39
+ // opposite of the gauge's neighbour rule (`modules/autonomy/gauge.js` holds when
40
+ // it cannot vouch for a reading) only in wording — both say the same thing. A
41
+ // machine that cannot prove it is allowed to act does not act.
42
+
43
+ // The refusal vocabulary. Fixed strings, because the run log, the digest and any
44
+ // future alert all key on them; free-text reasons ride alongside.
45
+ const REFUSALS = {
46
+ fence_unreadable: 'the fence could not be read, so nothing is permitted',
47
+ kill_switch: 'the kill switch is off — an owner stopped the runner',
48
+ no_allowlist: 'no goal is allowlisted, so there is nothing this runner may work on',
49
+ goal_not_allowlisted: 'that goal is not on the allowlist',
50
+ protected_path: 'the task declares work on a permission, migration, infrastructure or deploy-pipeline surface',
51
+ grader_unavailable: 'the grader is bypassed, and it is the only review between an unattended worker and main',
52
+ };
53
+
54
+ // normaliseFence — turn whatever the route returned into something decidable, or
55
+ // null for "unreadable". Deliberately strict: a shape this does not recognise is
56
+ // unreadable, never a permissive default.
57
+ function normaliseFence(raw) {
58
+ if (!raw || typeof raw !== 'object') return null;
59
+ // `enabled` is the ONE field that makes a fence readable: without a trustworthy
60
+ // boolean there is no answer to "am I allowed to run", and the honest report is
61
+ // that the fence could not be read.
62
+ if (typeof raw.enabled !== 'boolean') return null;
63
+ // A missing or malformed goals list is NOT unreadable — it degrades to an EMPTY
64
+ // allowlist, which is the most restrictive reading available and refuses just as
65
+ // hard. The difference is what the operator is told: a thrown kill switch should
66
+ // always be reported as a thrown kill switch, never buried under "unreadable"
67
+ // because a list beside it was the wrong shape.
68
+ const goals = Array.isArray(raw.goals) ? raw.goals : [];
69
+ const ids = [];
70
+ for (const g of goals) {
71
+ // A goal row may be a bare id or { goal_id }. Anything that is not a positive
72
+ // integer is DROPPED rather than coerced — Number('') is 0, and a 0 sitting
73
+ // in an allowlist is a goal id nobody meant to grant.
74
+ const n = Number(typeof g === 'object' && g !== null ? g.goal_id : g);
75
+ if (Number.isSafeInteger(n) && n > 0) ids.push(n);
76
+ }
77
+ return {
78
+ enabled: raw.enabled === true,
79
+ pausedReason: typeof raw.paused_reason === 'string' ? raw.paused_reason : null,
80
+ goals: ids,
81
+ };
82
+ }
83
+
84
+ // allowedGoals — the goals this run may work, given the fence and whatever the
85
+ // operator asked for on the command line.
86
+ //
87
+ // The INTERSECTION is the point. `requested` can only narrow: a goal the operator
88
+ // named that the database does not allow is dropped, and reported, rather than
89
+ // honoured. The runner host is not a place authority can come from.
90
+ function allowedGoals(fence, requested = []) {
91
+ const allowed = new Set(fence.goals);
92
+ const asked = (requested || []).map((g) => Number(g)).filter((n) => Number.isSafeInteger(n) && n > 0);
93
+ if (!asked.length) return { goals: fence.goals.slice(), refused: [] };
94
+ return {
95
+ goals: asked.filter((g) => allowed.has(g)),
96
+ refused: asked.filter((g) => !allowed.has(g)),
97
+ };
98
+ }
99
+
100
+ // decideRun — may this runner start an iteration at all?
101
+ //
102
+ // Called BEFORE the gauge and before anything is picked, because three of these
103
+ // four conditions make every task refusable and there is no sense costing a
104
+ // capacity read to discover it. `graderBypassed` comes from GET /me's
105
+ // `grader_bypass` (ADR 0041's global killswitch, resolved server-side); `null`
106
+ // means it could not be read, which is not the same as `false` and must not be
107
+ // treated as one.
108
+ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null } = {}) {
109
+ const fence = normaliseFence(rawFence);
110
+ if (!fence) return { go: false, code: 'fence_unreadable', reason: REFUSALS.fence_unreadable, goals: [] };
111
+
112
+ if (!fence.enabled) {
113
+ return {
114
+ go: false, code: 'kill_switch', goals: [],
115
+ reason: fence.pausedReason ? `${REFUSALS.kill_switch}: ${fence.pausedReason}` : REFUSALS.kill_switch,
116
+ };
117
+ }
118
+
119
+ // `true` and `null` both refuse. An unknown grader state is an unreviewed ship
120
+ // waiting to happen, and the fail direction here is to stop.
121
+ if (graderBypassed !== false) {
122
+ return {
123
+ go: false, code: 'grader_unavailable', goals: [],
124
+ reason: graderBypassed === null
125
+ ? 'could not read whether the grader is available — refusing rather than shipping unreviewed'
126
+ : REFUSALS.grader_unavailable,
127
+ };
128
+ }
129
+
130
+ const { goals, refused } = allowedGoals(fence, requestedGoals);
131
+ if (!goals.length) {
132
+ return {
133
+ go: false, goals: [], refusedGoals: refused,
134
+ code: refused.length ? 'goal_not_allowlisted' : 'no_allowlist',
135
+ reason: refused.length
136
+ ? `${REFUSALS.goal_not_allowlisted} (asked for ${refused.join(', ')})`
137
+ : REFUSALS.no_allowlist,
138
+ };
139
+ }
140
+ return { go: true, goals, refusedGoals: refused, code: null, reason: null };
141
+ }
142
+
143
+ // decideTask — may THIS task be handed to an unattended worker?
144
+ //
145
+ // `protectedHits` is the output of the ADR 0043 registry matcher run over the
146
+ // task's declared touches[], injected so this file stays pure — the matcher lives
147
+ // in core and a module reaches core only through the doorway. Passing the hits in
148
+ // also means the caller decides WHAT to match, which is what lets a future caller
149
+ // run the same decision over a real diff instead of a declaration.
150
+ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
151
+ if (!task || task.id === undefined || task.id === null) {
152
+ return { go: false, code: 'fence_unreadable', reason: 'no task to decide about' };
153
+ }
154
+ const goalId = Number(task.goal_id);
155
+ if (!goals.map(Number).includes(goalId)) {
156
+ return {
157
+ go: false, code: 'goal_not_allowlisted',
158
+ reason: `${REFUSALS.goal_not_allowlisted} (task ${task.id} is in goal ${task.goal_id ?? 'none'})`,
159
+ };
160
+ }
161
+ if (Array.isArray(protectedHits) && protectedHits.length) {
162
+ const globs = [...new Set(protectedHits.map((h) => h && h.glob).filter(Boolean))];
163
+ return {
164
+ go: false, code: 'protected_path',
165
+ reason: `${REFUSALS.protected_path}${globs.length ? ` (${globs.join(', ')})` : ''}`,
166
+ globs,
167
+ };
168
+ }
169
+ return { go: true, code: null, reason: null };
170
+ }
171
+
172
+ module.exports = { REFUSALS, normaliseFence, allowedGoals, decideRun, decideTask };
@@ -0,0 +1,71 @@
1
+ -- autonomy_001_autonomy_fence.sql — the Autobongos fence (task 1003908, ADR 0307 §6).
2
+ --
3
+ -- WHY THIS IS A TABLE AND NOT A FILE. The Autobongos runner is a program on an
4
+ -- always-on Windows PC that nobody is watching. Its allowlist — which goals a
5
+ -- worker may be spawned against — cannot live in a file on that machine, because
6
+ -- a file there is editable by anything running there, including the very
7
+ -- bypassPermissions worker the allowlist exists to bound. The owner's control
8
+ -- has to sit somewhere the machine can READ and cannot WRITE. That is the
9
+ -- server, behind an archon-gated route, which is what these two tables back.
10
+ --
11
+ -- THE DEFAULT IS OFF, and that is the whole safety argument. `enabled` defaults
12
+ -- to FALSE and the allowlist starts EMPTY, so a fresh instance that installs this
13
+ -- module gets a runner that refuses to do anything until a person deliberately
14
+ -- turns it on and names a goal. An allowlist that defaults to "everything" would
15
+ -- make the fence a formality the first time someone forgot to configure it.
16
+ --
17
+ -- ONE ROW, ENFORCED. autonomy_fence is a singleton (id smallint PRIMARY KEY
18
+ -- CHECK (id = 1)): a second row would mean two kill switches and no way to say
19
+ -- which one is live, which is the failure mode of every "settings table" that
20
+ -- forgot to pin its cardinality. The row is seeded here so every read is a plain
21
+ -- SELECT that cannot come back empty and be mistaken for "not configured yet" —
22
+ -- an empty read and a disabled fence must not look alike to a machine.
23
+ --
24
+ -- NO FOREIGN KEY ON goal_id, deliberately. A goal deleted out from under the
25
+ -- allowlist must leave a row that reads as "this goal is allowed and does not
26
+ -- exist" rather than silently removing the fence entry — the runner refuses a
27
+ -- goal it cannot fetch anyway (it halts on the fetch), and a CASCADE here would
28
+ -- turn a deletion into a silent widening of nothing into nothing. The cost is a
29
+ -- stale row the hall can show and an owner can clear.
30
+ --
31
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
32
+ -- object is autonomy_-prefixed (fitness.js refuses an autobongos_ name here, and it
33
+ -- is right to: the table belongs to the MODULE, not to the feature), there is
34
+ -- no down-migration, and the filename is autonomy_-prefixed (ADR 0108 §5).
35
+ -- Idempotent: safe to re-run.
36
+
37
+ BEGIN;
38
+
39
+ -- The kill switch, and the reason it was thrown. One row, forever.
40
+ CREATE TABLE IF NOT EXISTS autonomy_fence (
41
+ id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
42
+ -- FALSE = the runner does no work. Default off (see the header).
43
+ enabled boolean NOT NULL DEFAULT false,
44
+ -- Why it is off. Free text, shown in the hall and printed by the runner so a
45
+ -- machine that stopped can say WHO stopped it and why, not just "halted".
46
+ paused_reason text,
47
+ updated_at timestamptz NOT NULL DEFAULT now(),
48
+ updated_by bigint
49
+ );
50
+
51
+ -- Seed the singleton so a read can never be empty. ON CONFLICT keeps a re-run
52
+ -- from resetting a live instance's switch back to off.
53
+ INSERT INTO autonomy_fence (id, enabled, paused_reason)
54
+ VALUES (1, false, 'never enabled — no one has turned the runner on yet')
55
+ ON CONFLICT (id) DO NOTHING;
56
+
57
+ -- The allowlist. A goal is claimable by an unattended worker only if it is here.
58
+ CREATE TABLE IF NOT EXISTS autonomy_allowed_goals (
59
+ goal_id bigint PRIMARY KEY,
60
+ note text,
61
+ added_at timestamptz NOT NULL DEFAULT now(),
62
+ added_by bigint
63
+ );
64
+
65
+ -- The runner's read is "every allowed goal", newest first for the hall's list.
66
+ -- Small table by design (a handful of goals), so this index is for the ordering,
67
+ -- not the lookup — the lookup is the primary key.
68
+ CREATE INDEX IF NOT EXISTS autonomy_allowed_goals_added_idx
69
+ ON autonomy_allowed_goals (added_at DESC);
70
+
71
+ COMMIT;
@@ -27,6 +27,11 @@ const auth = api;
27
27
  // from OUTSIDE the kernel boundary, which a modules/ file may not do). scripts/ is
28
28
  // neither core internals nor a sibling module, so importing it here is allowed.
29
29
  const autonomyGate = require('../../../scripts/gds/autonomy-gate');
30
+ const db = require('../db');
31
+ // The doorway's namespaced logger and strict body validator. Raw console.* is
32
+ // ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
33
+ const log = api.logger('autonomy');
34
+ const { validateOrRespond } = api;
30
35
 
31
36
  // System 3 (task 1351): the autonomy precheck over HTTP. The deterministic
32
37
  // decision logic lives in scripts/gds/autonomy-gate.js (run directly as a CLI
@@ -52,5 +57,91 @@ module.exports = function buildAutonomyRouter() {
52
57
  }
53
58
  });
54
59
 
60
+ // ── the Autobongos fence (task 1003908, ADR 0307 §6) ───────────────────────
61
+ //
62
+ // These four routes ARE the fence. Everything the unattended runner is allowed
63
+ // to do is decided by rows only these can write, on a server the runner host
64
+ // cannot reach — that separation is the entire safety argument, so read the
65
+ // rank gates below as load-bearing rather than boilerplate.
66
+
67
+ // The runner's own read, every iteration. ANY-BUILDER on purpose: learning that
68
+ // you must stop should never require an elevated session, because the failure
69
+ // mode of "could not read the fence" is a machine that keeps going. The body
70
+ // carries no secret — it is a boolean, a reason string and a list of goal ids.
71
+ router.get('/autonomy/fence', auth.requireBuilder, async (req, res) => {
72
+ try {
73
+ const fence = await db.readFence();
74
+ // Null means the singleton row is missing, i.e. the migration has not run.
75
+ // Say so rather than synthesising a default: a fabricated { enabled: false }
76
+ // is indistinguishable from a real one, and an operator would chase a kill
77
+ // switch nobody threw.
78
+ if (!fence) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
79
+ res.json(fence);
80
+ } catch (err) {
81
+ log.error('[gds] GET /autonomy/fence', err);
82
+ // FAIL CLOSED, unlike the precheck above. That one is fail-open because an
83
+ // autonomy gate must never become a new way to break a routine; this one is
84
+ // the opposite question — an unreadable fence must never read as permission.
85
+ res.fail('fence_unreadable', { status: 503, message: 'could not read the autonomy fence' });
86
+ }
87
+ });
88
+
89
+ // The kill switch. Archon, system:true — see the catalog entry for why it is the
90
+ // one permission that cannot be delegated to a custom rank.
91
+ router.post('/autonomy/fence', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
92
+ if (validateOrRespond(req, res, {
93
+ enabled: { required: true, type: 'boolean' },
94
+ reason: { type: 'string', maxLength: 500 },
95
+ })) return;
96
+ const body = req.body || {};
97
+ const reason = typeof body.reason === 'string' ? body.reason : null;
98
+ try {
99
+ const row = await db.setFenceEnabled({ enabled: body.enabled, reason, builderId: req.builder.id });
100
+ if (!row) return res.fail('fence_uninitialised', { status: 503, message: 'the autonomy fence row is missing — run the module migrations' });
101
+ // Logged at info, always. Throwing this switch is the single most consequential
102
+ // thing anyone does to the unattended runner, and an ops surface that changes
103
+ // silently is one nobody can reconstruct afterwards.
104
+ log.info(`[gds] autonomy fence ${body.enabled ? 'ENABLED' : 'DISABLED'} by builder ${req.builder.id} (${req.builder.rank})${reason ? `: ${reason}` : ''}`);
105
+ res.json(row);
106
+ } catch (err) {
107
+ log.error('[gds] POST /autonomy/fence', err);
108
+ res.fail('fence_write_failed', { status: 500, message: 'internal error' });
109
+ }
110
+ });
111
+
112
+ // Add a goal to the allowlist.
113
+ router.post('/autonomy/fence/goals', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
114
+ if (validateOrRespond(req, res, {
115
+ goal_id: { required: true }, // number or stringified number; coerced below
116
+ note: { type: 'string', maxLength: 500 },
117
+ })) return;
118
+ const goalId = Number(req.body?.goal_id);
119
+ if (!Number.isSafeInteger(goalId) || goalId <= 0) return res.fail('goal_id_required', { status: 400, message: 'pass { "goal_id": <number> }' });
120
+ const note = typeof req.body?.note === 'string' ? req.body.note : null;
121
+ try {
122
+ const row = await db.allowGoal({ goalId, note, builderId: req.builder.id });
123
+ log.info(`[gds] autonomy fence: goal ${goalId} ALLOWED by builder ${req.builder.id} (${req.builder.rank})`);
124
+ res.status(201).json({ goal: row });
125
+ } catch (err) {
126
+ log.error('[gds] POST /autonomy/fence/goals', err);
127
+ res.fail('allow_failed', { status: 500, message: 'internal error' });
128
+ }
129
+ });
130
+
131
+ // Revoke one. `removed` distinguishes "just revoked" from "already gone" — an
132
+ // owner clicking Remove deserves to know which happened.
133
+ router.delete('/autonomy/fence/goals/:goalId', auth.requireBuilder, auth.requirePermission('autonomy.fence.manage'), async (req, res) => {
134
+ const goalId = Number(req.params.goalId);
135
+ if (!Number.isSafeInteger(goalId) || goalId <= 0) return res.fail('bad_goal_id', 400);
136
+ try {
137
+ const out = await db.disallowGoal(goalId);
138
+ log.info(`[gds] autonomy fence: goal ${goalId} REVOKED by builder ${req.builder.id} (${req.builder.rank}) — removed=${out.removed}`);
139
+ res.json(out);
140
+ } catch (err) {
141
+ log.error('[gds] DELETE /autonomy/fence/goals/:goalId', err);
142
+ res.fail('revoke_failed', { status: 500, message: 'internal error' });
143
+ }
144
+ });
145
+
55
146
  return router;
56
147
  };
@@ -172,6 +172,14 @@ function createClient(opts = {}) {
172
172
  getAuthWebStart: (args) => request("GET", "/auth/web/start", { hasBody: false }, args),
173
173
  },
174
174
  "autonomy": {
175
+ // GET /autonomy/fence — rank: any-builder — GET /autonomy/fence
176
+ getAutonomyFence: (args) => request("GET", "/autonomy/fence", { hasBody: false }, args),
177
+ // POST /autonomy/fence — rank: archon — POST /autonomy/fence
178
+ postAutonomyFence: (args) => request("POST", "/autonomy/fence", { hasBody: true }, args),
179
+ // POST /autonomy/fence/goals — rank: archon — POST /autonomy/fence/goals
180
+ postAutonomyFenceGoals: (args) => request("POST", "/autonomy/fence/goals", { hasBody: true }, args),
181
+ // DELETE /autonomy/fence/goals/{goalId} — rank: archon — DELETE /autonomy/fence/goals/:goalId
182
+ deleteAutonomyFenceGoalsGoalId: (args) => request("DELETE", "/autonomy/fence/goals/{goalId}", { hasBody: false }, args),
175
183
  // GET /autonomy/precheck — rank: metic+archon — GET /autonomy/precheck
176
184
  getAutonomyPrecheck: (args) => request("GET", "/autonomy/precheck", { hasBody: false }, args),
177
185
  },
@@ -109,6 +109,17 @@ const PERMISSIONS = [
109
109
  // so this cannot be delegated downward by a custom rank. The web tier still gains
110
110
  // no shell - the route only QUEUES an intent the control-plane runner drains.
111
111
  { key: 'core.pin.move', system: true, floor: 'archon', guards: 'POST /provisioning/instances/:id/core-upgrade — gate 3 of the release pipeline (ADR 0293)' },
112
+ // autonomy.fence.manage (task 1003908, ADR 0307 §6) — THE AUTOBONGOS KILL
113
+ // SWITCH and the allowlist of goals an unattended runner may work. system:true
114
+ // + archon for core.pin.move's reason, in a slightly sharper form: this decides
115
+ // whether a machine nobody is watching spawns sessions with blanket
116
+ // bypassPermissions against the repo. The catalog's own invariant forbids
117
+ // granting a system key to metic, so it cannot be delegated downward by a custom
118
+ // rank — which is the property that makes it a stop button rather than a
119
+ // suggestion. Deliberately NOT paired with a read permission: seeing whether the
120
+ // runner is on is harmless, and GET /autonomy/fence is any-builder so the runner
121
+ // itself does not need an elevated session merely to learn it must stop.
122
+ { key: 'autonomy.fence.manage', system: true, floor: 'archon', guards: 'POST /autonomy/fence (the kill switch), POST|DELETE /autonomy/fence/goals (the allowlist) — the Autobongos fence (ADR 0307 §6)' },
112
123
  // BV1.R21 (task 1003608, goal 1000086). ADR 0250 §6 amends ADR 0157 for exactly
113
124
  // this key: `version.create` returns to the Archon floor.
114
125
  //
@@ -0,0 +1,37 @@
1
+ -- government_016_autonomy_fence_manage.sql — grant the new `autonomy.fence.manage`
2
+ -- atom to the Archon (task 1003908, ADR 0307 §6).
3
+ --
4
+ -- WHAT IT IS. The Autobongos kill switch and the allowlist of goals an unattended
5
+ -- runner may work. The runner is a program on an always-on machine that spawns
6
+ -- Claude sessions with blanket bypassPermissions; this key gates the only surface
7
+ -- that can stop it or change what it is allowed to touch.
8
+ --
9
+ -- ARCHON ONLY, one block. The catalog declares the key `system: true, floor:
10
+ -- 'archon'` — the same shape as `core.pin.move` (government_014) and for a sharper
11
+ -- version of its reason. A stop button the thing being stopped can be granted the
12
+ -- power to press is not a stop button, and the catalog's own invariant (a system
13
+ -- key is never grantable below its floor) is what makes that structural rather
14
+ -- than a convention. So there is no metic rung here, deliberately: adding one
15
+ -- would be the whole safety property, undone quietly in a migration.
16
+ --
17
+ -- WHY THIS FILE EXISTS AT ALL. RANK_SEED is derived from the catalog's floors, and
18
+ -- the drift guard (tests/government_seed.mjs) compares it against the UNION of the
19
+ -- run-once seed plus every later grant migration, per rank. Minting the catalog row
20
+ -- without this migration leaves the Archon holding the key on paper and not in the
21
+ -- database — which is exactly what that guard catches, and it did.
22
+ --
23
+ -- Additive only: one new permission key granted to one rank; nothing is revoked.
24
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
25
+ --
26
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
27
+ -- scans for it literally, and a combined form parses as zero grants and reds it.
28
+
29
+ BEGIN;
30
+
31
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
32
+ SELECT 'archon', unnest(ARRAY[
33
+ 'autonomy.fence.manage'
34
+ ])
35
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
36
+
37
+ COMMIT;
@@ -43,6 +43,19 @@
43
43
  with a verdict band along its top is gone (task 1003469). -->
44
44
  <div class="stat-row ov-summary" id="gate-summary" aria-label="Held changes summary"></div>
45
45
 
46
+ <!-- The Autobongos fence (task 1003908, ADR 0307 §6). It lives on THIS
47
+ page because the Gate is already the owner's protected-surface
48
+ surface, and because the switch and the held PRs answer the same
49
+ question from two ends: what is allowed to reach main unattended.
50
+ Archon-only — the panel stays hidden for a Metic, and the routes
51
+ behind it are system:true/archon server-side regardless. -->
52
+ <section class="scroll" id="fence-panel" aria-labelledby="fence-h" hidden>
53
+ <h2 class="scroll__h" id="fence-h">Autobongos runner</h2>
54
+ <p class="scroll__lede">The unattended runner works only the goals listed here, and only while it is on. This is the switch — it lives on the server, so the machine can read it and cannot change it.</p>
55
+ <div id="fence-state"><span class="kit-skel"></span></div>
56
+ <div id="fence-goals"></div>
57
+ </section>
58
+
46
59
  <section class="scroll" aria-labelledby="gate-list-h">
47
60
  <h2 class="scroll__h" id="gate-list-h">Held pull requests</h2>
48
61
  <p class="scroll__lede">Each one touched a protected surface and stopped at the gate check. Approving records your seal on that commit and re-runs the check.</p>
@@ -65,6 +78,6 @@
65
78
  <script src="/builders/palette.js" defer></script>
66
79
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
67
80
  <script src="/builders/hall-kit.js"></script>
68
- <script src="/builders/gate.js?v=2026-09-02-gate-rows"></script>
81
+ <script src="/builders/gate.js?v=2026-09-19-fence"></script>
69
82
  </body>
70
83
  </html>