@bongos/core 1.19.873 → 1.19.875
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.
- package/.bongos-core.json +56 -31
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +18 -0
- package/docs/api/openapi.json +218 -3
- package/docs/api-reference.md +6 -2
- package/docs/copy-inventory.md +48 -39
- package/docs/copy-registry.json +152 -71
- package/docs/module-api-changelog.md +4 -0
- package/modules/autonomy/db.js +74 -0
- package/modules/autonomy/fence.js +172 -0
- package/modules/autonomy/migrations/autonomy_001_autobongos_fence.sql +71 -0
- package/modules/autonomy/routes/autonomy.js +91 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/government/catalog.js +11 -0
- package/modules/government/migrations/government_016_autonomy_fence_manage.sql +37 -0
- package/modules/hall-ui/public/deploy.html +4 -4
- package/modules/hall-ui/public/deploy.js +189 -58
- package/modules/hall-ui/public/gate.html +14 -1
- package/modules/hall-ui/public/gate.js +117 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-run.js +90 -15
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +262 -0
- package/tests/autobongos_loop.mjs +120 -1
- package/tests/deploy_page_projects.mjs +193 -0
|
@@ -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;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Deploy — {{worldName}}</title>
|
|
7
|
-
<meta name="description" content="Move this
|
|
7
|
+
<meta name="description" content="Move this platform or any project it hosts onto a newer core, and read back the version each box actually serves — the deploy gate, without a shell or an ssh key.">
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
10
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
mean visible (ADR 0137's three gates); this page is the third one. -->
|
|
39
39
|
<p class="scroll__lede">
|
|
40
40
|
Merging work does not put it in front of anyone. This is the step that does:
|
|
41
|
-
it moves this
|
|
42
|
-
puts
|
|
41
|
+
it moves this platform — or any project it hosts — onto a newer core, waits
|
|
42
|
+
for that box to answer, and puts it back if it does not.
|
|
43
43
|
</p>
|
|
44
44
|
<!-- One mount, written by deploy.js. Nothing here is authored as markup with
|
|
45
45
|
a version in it: every number on this page is a reading the control plane
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
<script src="/builders/palette.js" defer></script>
|
|
60
60
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
61
61
|
<script src="/builders/hall-kit.js"></script>
|
|
62
|
-
<script src="/builders/deploy.js?v=2026-09-
|
|
62
|
+
<script src="/builders/deploy.js?v=2026-09-18-deploy2"></script>
|
|
63
63
|
</body>
|
|
64
64
|
</html>
|