52 KiB
Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
Brief: docs/plans/2026-10-20_agent-lifecycle.md (blob a907109409). Gitea #1692. MVP T1 item 5 (mos-0974,
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
Branch honker/row194.
Related designs read: row 190 agents/darkwing/work/queue-190/DESIGN.md (r2, after mos-0986), row 191
agents/rocko/work/queue-191/DESIGN-r2.md. Row 193 is still in design. All line numbers below are at
53bb59462.
Summary
- One verb family on the CLI,
mosaic business instance <verb> BUSINESS INSTANCE, and one Console section. Create and delete change the business file. Launch, terminate and regenerate act on runs. - Every human verb goes through the bus with the human proof. Launch, regenerate and delete are requests the
launcher takes once and carries out under
serialize. Launches go through the onestart(), so--no-launch, the limits,launch.revokeand row 191's switch all apply. - The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
role.hireandrole.fireare new hard waits, so Jason resolves each one (Mos, mos-1017). The routed form waits on a PRD 1.7 draft in this packet. - A hire allowlist in code: the chief can hire only a definition whose every action is on
HIRE_ALLOWED(message, record, review request, assigned-task updates, and two actions that matter after row 199). Launch, end, organization, naming, hire, verdict, merge and every later action stay out. - The per-agent forbid is two lists in the business file that only the human writes:
launch.keep(instances the chief can't fire, replace or end) andlaunch.noHire(definitions the chief can't hire). - The broker reloads a business from its file after each committed write. Create, delete, hire, fire and
veto changes then count without a bus restart. Hand edits still need one, except
bus.tester. - No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the
role-file
hireablekey,"hireable": []inroles/chief-of-staff.json, and three business-file keys.
What exists today (checked at 53bb59462)
| # | Fact | Where |
|---|---|---|
| E1 | Instances are keys of the business file's roles. launch.instances lists those the chief may launch. The list must be non-empty |
business.mjs:121-132 |
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | instance-settings.mjs:1-5, 22-31, 75-156 |
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | broker.mjs:208, 446 |
| E4 | The chief launches over launch.sock. fromSession checks identity, then check() (which reloads the file), then authorizeLaunch, then start() |
launcher.mjs:232-327, 624-668 |
| E5 | The chief's launch tool takes an enum of the instances listed when its bundle was built |
harness/src/tools.mjs:116-119 |
| E6 | The chief ends a run with role.revoke plus a reason (role.end). Ending then launching again is already a replace |
broker.mjs:1601-1628, tools.mjs:167 |
| E7 | mosaic stop RUN is a same-uid SIGTERM. It has no bus call and no human proof |
cli.mjs:447-454, seat/src/session.mjs:249-270 |
| E8 | The Console stops only the assistant (assistant.stop) and writes instances through instances.set |
serve.mjs:282-289, 315-363, 535 |
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | launcher.mjs:133, 487-597 |
| E10 | launch.revoke and launch.restore are human-only. A revoked business refuses role.launch |
broker.mjs:770-774, 2087-2095 |
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | vocabulary.mjs:135, roles/*.json |
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human listed. It has no way to create an instance, and the human has no way to stop the chief ending a given instance's runs.
D1. The verbs
All of them are human verbs. Each is a broker verb behind #human(s) and then the D4 hook. Each logs its
actor. The CLI usage joins INSTANCES_USAGE (cli/src/instances.mjs:9).
| Verb | CLI | Bus verb | Effect |
|---|---|---|---|
| V1 create | business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV |
instances.set op add |
as today, with no restart (D3) |
| V2 launch | business instance launch B I [--wait SECONDS] |
instance.launch |
one run, through the launcher |
| V3 terminate | business instance terminate B I [--reason TEXT] |
instance.end |
ends every run of I and cancels I's open requests |
| V4 regenerate | business instance regenerate B I [--wait SECONDS] |
instance.regenerate |
V3, then V2 once the run has exited |
| V5 delete | business instance delete B I --revision REV [--wait SECONDS] |
instances.set op delete |
a request. The launcher removes I from the business file and renames its workspace |
| V6 veto | business veto B (keep|unkeep) I and business veto B (nohire|hire) DEF, each --revision REV |
veto.set |
edits launch.keep and launch.noHire |
mosaic business instances B (read) gains, per instance: running (run ids from the bus claims), kept
and retired. The top level gains noHire. mosaic stop RUN stays as it is. It is the same-uid operator
tool, and V3 is the human verb.
D1.1 Requests (V2, V4, V5)
The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the
launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken
and closed, all recorded as action.allowed events with reserved action names:
| Event | Written by | Body |
|---|---|---|
lifecycle.launch, lifecycle.regenerate, lifecycle.delete |
the broker, at the verb | { request, via }, subject I, actor the human or tester. A human.input event goes with it, as launch.revoke does |
lifecycle.taken |
host op lifecycleTake |
{ request } |
lifecycle.done |
host op lifecycleDone |
{ request, run? , refusal? } |
lifecycle.cancelled |
V3, for each open request of I | { request, by } |
- The launcher's new
lifecyclePassruns on the same tick asthreadPassandlandPass. It lists open requests with host oplifecycleRequests. - For each one, under
serialize, it callslifecycleTake(request). That op runs in one store transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is less than 60 s old (else it closes asrequest-expired), the business isn't revoked (launch_state,launch-revoked), I isn't retired, row 191's switch is off (processing-halted) for launch and regenerate, and for a tester requestbus.testeris true in the file (D4.1). Any failure closes the request with that code. Otherwise it writeslifecycle.takenand answers go. - Only after go does the pass act:
check(I, { human: true })thenstart()for a launch, the V5 steps for a delete. - The pass then calls
lifecycleDone. A taken request never runs again, whatever happens after the take. IflifecycleDonethrows, the request stays taken with no done event. The CLI reports that asoutcome-unknownwith the request id, and the launcher log names the run. - The CLI polls a read verb,
instance.request, until the request closes or--waitruns out (default 30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.
So the human's launch.revoke stops every untaken request, not only new ones (B4), and a bus restart
can't start a request older than 60 s.
V2 launch
- At the verb the broker checks that I exists, isn't a thread instance (
not-thread-launch: threads start on a message), isn't retired, and that the business isn't revoked and, with row 191, isn't halted. These repeat at the take (D1.1 item 2). - After the take,
check(I, { human: true })andstart({ instance: I, launchedBy }), wherelaunchedByis the business human ortester.human: truelets the human launch an instance that isn't inlaunch.instances. The listing limits the chief, not the human.launch.bystill goes through thepm: truebranch (launcher.mjs:235), as--pmdoes today. Every other step ofcheck()andstart()is unchanged.
V3 terminate
- The broker revokes every claim I holds (
#claimEnd(s, h, 'revoke', null, reason, actor)). A thread instance has all its thread claims revoked, which is exactlyassistant.stop. - In the same transaction it writes
lifecycle.cancelledfor every open (untaken) request of I, so no launch the human asked for before the terminate starts after it (B5). - It writes
lifecycle.endwith the runs. The launcher pass sends SIGTERM to each run's runner after checking its start time, as the idle stop does (launcher.mjs:526). It also signals any run of I in the launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit handler records the end, so the launch journal andsession.endedare unchanged. - Nothing held, nothing open and nothing in the registry answers
not-held. - Terminate is allowed while row 191's switch is on. It starts nothing.
A taken launch request is past cancelling. Its run reaches the registry under serialize, and the next
terminate ends it. The CLI's terminate output says so when a taken request exists.
There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. mosaic stop and the
limits are the operator's tools for it (N6).
V4 regenerate
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the
request, waits for the old run's exit handler, then runs check() and start(). The workspace stays, as
it does across every launch today. The session is new because every non-thread launch gets a new run dir
(seat/src/session.mjs:107, newRun). If the old run hasn't exited by the request's 60 s mark, the request
closes as regenerate-timeout, nothing starts, and the old run is still running (N6).
A thread instance (the assistant) refuses regenerate-thread. Its context lives in the thread's session
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
V5 delete
instances.set gains op delete. At the verb the broker and the writer refuse, in this order:
| Refusal | When |
|---|---|
tester-refused |
the caller is the tester (B6) |
organization-definition-refused |
I is END_PROTECTED |
organization-instance-referenced |
I is launch.by or an arbiter. The message names the reference |
organization-instance-kept |
I is in launch.keep. The human runs veto unkeep first. Delete never clears a veto silently |
organization-credentials-out-of-scope |
I holds credentials or a tracker bot. That stays a hand edit until the credentials row (row 199) |
instance-running |
the broker sees a claim for I. The human terminates first |
instance-starting |
an open or taken request for I has no done event |
| BusinessError | I is the last name in launch.instances (E1). The message says so |
The checks pass with no write, and the broker records a lifecycle.delete request. After the take, the
launcher pass, under serialize so no start() of I runs alongside it:
- Re-checks the registry for I (
instance-running) and re-runs the writer's checks against the file as it stands. The--revisionthe human gave must still match, elserevision-conflict. - Commits the file through the organization writer. It removes
roles.Iand the references that exist only for I: itslaunch.instancesentry, itslaunch.limits.tokens.instancesentry and its per-instance model entry. It adds I to a new top-level list,retired. The build lists every placevalidateBusinessDocumentreads a role instance name and puts each one in one of the two groups (refuse or remove). A reference the table doesn't name stops the build and goes back to Webby. - Renames
workspaces/<b>/I/toworkspaces/<b>/.retired/I-<transaction>/, mode 0700. No workspace is no rename. - Calls
lifecycleDonewith the transaction.
The file goes first. Once it is committed, check() refuses I as unknown, so no launch can open the old
workspace even if step 3 fails. A failed rename closes the request with workspace-rename-failed, the
transaction and the path left behind. A second delete of a retired name skips steps 1 and 2 and retries
step 3 only. The file and evidence are already right, so this is the whole recovery.
What stays:
- Run evidence (
launches/<b>/<run>/,launches/<b>.jsonl) and bus rows are untouched. They are write-once. - The workspace, under
.retired. This row deletes no file. A prune with a receipt is a later act. retiredstops a later create or hire from reusing the name. Decisions bind to the role instance and outlive restarts (broker.mjs:783-788). A newcoderwould otherwise inherit an approval raised by the old one. The tradeoff is a name that can't come back without a hand edit. I chose that over voiding decisions, which would touch a second store.
V6 veto
veto.set args: { revision, op: "keep" | "unkeep" | "nohire" | "hire", name }. It goes through the
organization writer, so it is logged with actor and diff in changes.jsonl. keep needs a declared,
non-END_PROTECTED instance. nohire needs a name that matches the id pattern. A no-op answers
organization-no-change. It is human-only and isn't in TESTER_ORGANIZATION_VERBS.
D2. Chief hire and fire
D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)
This row adds role.hire and role.fire to ACTIONS (vocabulary.mjs:11). Under PRD REQ-WAIT-3 a new
action is a hard wait until a PRD version says it isn't (vocabulary.mjs:52-57), so both ship as hard
waits. Jason resolves each one. There is no PRD change and no grant in a role file: role.mjs:42 refuses
a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.
The flow in this row:
- The chief raises a
role.hireorrole.firedecision whose body carries the arguments (business, name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it. - Jason resolves it in the Console or the CLI.
- The chief calls
instance.hireorinstance.fireciting the decision. The broker checks it with#consumeAuthority(s, 'role.hire', { decision, target }), as therole.revokedecision branch does, and the arguments must equal the decision body field by field (N1, webby-0010). For hire that isname,definitionandvars(vars compared withcanonicalJson, absent equal to absent only). For fire it isnameandreason. A key in the call that the body lacks, or a body key the call lacks, refusesdecision-mismatch. Every check below runs again at this call, so a veto set after the resolve still refuses.
The routed form, where the chief hires and fires without a decision each time, needs a PRD version that
moves both actions to ROUTED. My draft is agents/honker/work/queue-194/PRD-1.7-DRAFT.md. It quotes
TARGET.md line 66 and carries Mos's bounds. Jason approves it as a prd.approve decision in the Console,
and Sage opens that decision. When it lands, a later commit adds the two actions to both ROUTED lists
(vocabulary.mjs, broker.mjs:43-69) and to roles/chief-of-staff.json withinRole. The broker then
takes the reason form instead of a decision. The verbs and every check below stay the same. This row
builds and tests the hard-wait form only. It writes the hireable key and its allowlist now (D2.3), because
both forms use them.
D2.2 What the chief may do
| Act | Bus verb | Rule |
|---|---|---|
| launch | launch (host) |
as today: listed instances only |
| end a run | role.revoke + reason |
as today, and refused instance-kept when the target is kept (D2.4) |
| hire | instance.hire { name, definition, vars?, decision } |
adds and lists an instance. Refusals below |
| fire | instance.fire { name, reason, decision } |
ends I's runs, cancels I's open requests, and removes I from launch.instances. Refusals below |
Only the business's chief, the instance named by launch.by, may call either verb. Any other caller
refuses role-not-chief, even with a resolved decision, as #classification already limits
role.launch. The broker reads hireable from that chief's own role file. Both act only in the chief's
own business. Neither verb takes a business argument. The broker uses the
cap's business, and a decision raised in another business refuses decision-mismatch. Row 191's switch
refuses both while halted (D6). Every hire and fire writes changes.jsonl through the organization writer
with actor the chief's instance and run, and the decision id.
Hire refuses, in order:
definition-not-hireablewhen the definition holds an action outsideHIRE_ALLOWED(D2.3), whatever any list says.definition-not-hireablewhen the definition isn't in the chief role file'shireablelist, or is in the business'slaunch.noHire.organization-definition-refusedfor END_PROTECTED,organization-credentials-out-of-scopefor a credentialed definition (until the credentials row, row 199),invalid-requestfor version 1.organization-instance-retiredwhen the name is retired.- When the name exists: a fired instance (in
roles, not listed) is listed again only ifdefinitionequals its current one andvarsis absent. Any other definition or vars refusesorganization-instance-exists, and so does a listed name. A re-hire never moves an instance's decisions to a different role (B3).
Hire's vars may hold only the keys the chief can already set on an instance through organization.set
(the model key, broker.mjs:1266-1269) and narrowing limits.* keys. Anything else, such as harness
or a route, refuses invalid-request. The human sets those (N3).
Fire refuses instance-kept for a kept instance, organization-definition-refused for END_PROTECTED,
organization-instance-referenced for launch.by, and a BusinessError for the last listed instance. A
fired instance stays in roles. The chief never deletes. Delete stays a human act because it retires the
name.
Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run
gets the shared model token at start: ENV_ALLOW (packages/seat/src/session.mjs:83-94) passes
ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN and the other provider variables into the sandbox. A revoke
can't take that token back, so a fired agent whose process lives on still holds it.
The delist is a business file commit through the organization writer, and the revoke and events are store rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:
A. One store transaction: steps 1 to 3 below, plus fire.stopped with the decision id. The decision isn't
consumed yet.
B. The delist through the organization writer (launch.instances loses I, logged in changes.jsonl).
C. One store write consuming the decision and recording fire.done.
If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the
same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire
(unlike V3's not-held). B finds I already delisted and counts as done. C consumes. So a fire is safe to
repeat until it's consumed. The other order would leave a delisted agent with a live process and its model
token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop
B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop
covers the runs.
Steps 1 to 3 are V3's, with one change:
- It revokes every claim the instance holds.
- It writes
lifecycle.cancelledfor each of the instance's open requests. - It writes
lifecycle.endwith the runs andkill: true. The launcher pass stops each runner, spawned but unbound registry runs included, with escalation (sage-1380 item 1):- SIGTERM after the identity check.
- After the grace, SIGKILL to any runner still alive, after the same identity check again, and
lifecycle.end.killedwith the run and pid. - A runner still alive after SIGKILL and the observe wait gets
lifecycle.end.alive. The Console's Agents section shows it to the human. A runner whose identity no longer matches is never signalled. Every check, the recheck after the grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never counts as the fired run (webby-0011 N2).
Fire's answer lists the runs it is stopping. The delist stops any new chief launch (launch takes listed
instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the
fire answer names it, and the human's terminate ends its run.
The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's
design r2 (agents/rocko/work/queue-191/DESIGN-r2.md, "Stop policy and completion") sends SIGTERM, waits
5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start
ticks plus boot id, which replaces the start-time check at launcher.mjs:526. Fire calls that routine for
one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:
- Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
- A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt (row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or restarting the deadline, and counts the run in its remaining or quiescent totals.
- The record says which stop owned it: reason
firewith the decision, or row 191'skill-switchwith its generation.
V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177), as D6 already says.
D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)
r2 and r3 had a deny list, HIRE_FLOOR. Webby's B1 showed it missed routed actions (git.merge.protected,
which roles/merge-gate.json holds with no credentials, decision.resolve.technical, policy.change,
prd.approve), and that any action added later would be hireable by default. That isn't fail-closed. r4
replaces it with an allowlist.
A definition is hireable only if every action in its withinRole and crossRole is on HIRE_ALLOWED:
| Allowed action | Held today by |
|---|---|
message.send, record.write |
every v2 role file |
review.request, task.update.assigned |
coder, cto, pm, reviewer |
task.scope.change, git.push.working |
coder, cto, pm (credentialed, so they matter only after row 199) |
Everything else in ACTIONS fails, including the actions of the old floor (role.launch, role.end,
role.revoke, organization.change, instance.name, review.verdict, role.hire, role.fire) and those
B1 named. An action added to ACTIONS later fails until a reviewed commit adds it here.
git.push.remote (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays
unhireable until a reviewed commit adds it, after the credentials bound is lifted.
Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail.
Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and
coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only message.send and
record.write, so hiring one gives no authority beyond its name. hireable still has to list it.
The list is a constant in business/src/vocabulary.mjs, HIRE_ALLOWED. It is checked twice:
- At load.
role.mjsvalidates the chief role file'shireablelist: distinct definition names, each present inrolesDir, version 2, and every action onHIRE_ALLOWED. A list naming one that isn't refuses the role file (hireable names <def>, which holds <action>). The check reads only the named file's authority lists, never itshireable, so a list can't recurse.hireableis an optional key on any role file. This is a role schema change, named for review. - At hire, in the broker, against the definition as loaded then. A role file edited after load is caught here.
hireable in roles/chief-of-staff.json stays empty in this row ([], Q4, sage-1374). Naming the first
role kind is a separate policy commit in roles/, made after Jason resolves the prd.approve decision, as
AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve,
until that commit. The row's tests use scratch role files whose hireable names a kind.
D2.4 The per-agent forbid and the veto (sage-1365 item 3)
TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent". The forbid is two lists in the business file that only the human writes:
-
launch.keep: instance names. The chief can't fire, replace or end a kept instance. The broker refusesinstance-keptfor every chief act aimed at it except launch:instance.fire, even with a resolvedrole.firedecision. Mos's limit is that Jason's veto applies to every hire and fire, so the human unkeeps before a fire.- a hire of that name.
role.revokein the reason branch (broker.mjs:1607-1613). End then launch is a replace (E6).- an
organization.setwrite of that instance's model key (B7). A model change is a replace at the next launch.
The
role.revokedecision branch (broker.mjs:1614-1615) still ends a kept run. It ends a run, it doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3 and the limits also still end a kept run. -
launch.noHire: definition names the chief can't hire into this business (D2.2 item 2).
Why the chief can't change the forbid:
- Only
veto.setwrites the lists. It is human-only (#human(s)then the D4 hook) and refuses the tester. organization.set, the chief's one write verb, reaches onlyORGANIZATION_KEYS(broker.mjs:145), which excludelaunchandretired.instances.setis human or tester only (broker.mjs:1248-1298).- Hire and fire carry no field that writes either list.
T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.
END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's
runs, and the assistant's own stop is assistant.stop. Default, per "unless user forbids": nothing is
kept and nothing is on noHire.
D2.5 Where it lives and how the broker enforces it
The forbid lives in the business file, under launch, beside the membership it limits:
launch.keep: [instance], launch.noHire: [definition], and top-level retired: [instance]. hireable
lives in the chief's role file, because it bounds the role, not one business.
checkLaunch (business.mjs:118) gains the two keys. Each list is distinct. keep names declared,
non-protected instances. noHire entries need only the id pattern (N2). A stale deny entry is harmless,
and a reviewed commit that removes a role file must not stop every business that lists it. retired
names none of roles. This is the business-file schema change for review. Old files without the keys load
unchanged. An old bus loader rejects a file that has them (rejectUnknownKeys), so a rollback past this
row needs the keys removed first. The activation plan has to say so (R4).
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
role.revoke reason branch, in organization.set's model write and in the two chief verbs, before any
write.
D3. Live changes
Today the broker's #businesses is fixed at start (E3), and instances.set answers restart: true for
add and redefine.
Broker.reloadBusiness(id, document)swaps in one business's validated document. It is synchronous, so no request sees half of it. It refuses an unknown id and a document whosehumanchanged (that is row 193's ground).- The runtime's organization callback (
runtime.mjs:121-133) calls it in one place, after every verb that commits a write, whatever the verb (N1). Today that islimits.set,instances.set,routes.setandplugins.set, and after this rowveto.set,instance.hire,instance.fireand the launcher's delete through the same writer. It loads from the file through the loaderbootConfiguses (cli/src/config.mjs:88-105), so the broker holds exactly what a restart would give it. Thenlimits.refresh(id)runs, asplugins.setdoes now. - Other startup consumers of
businessesinstartBroker(runtime.mjs:75-81): credential references, usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker bot and no web grant, because credentialed definitions are refused. The build checks each consumer against that claim. Any change that would need one of them still answersrestart: trueand doesn't reload. - A definition change on a running instance refuses
instance-running. A live cap must not change authority under its run. This is new: today the change waits for the restart. Existing tests that pinrestart: truechange with it (N4):business/tests/instance-settings.test.mjs,bus/tests/instances-verb.test.mjs,cli/tests/instances.test.mjs:76-94(theRESTART_NEEDEDtext and the restart line) andwebui/tests/instances.test.mjs:25, 71, 74, 140-145. The build names each changed assertion in its manifest. - The chief's
launchtool (E5) takesinstanceas a string with the id pattern instead of the start-time enum. Its description says the host refuses an unlisted instance.check()already refuses one (launcher.mjs:235). Without this, the chief can't launch its own hire until its session restarts.tools.mjsmay carry hash pins in tests. The build greps for them (R5). - Hand edits of the business file still need a restart. Nothing watches the file. The one exception is
bus.tester(D4.1), read from the file at each tester call.
After this, restart is false for create, delete, hire, fire and veto changes, and true only under item 3.
D4. RBAC until row 193
Every human verb above calls #human(s) and then one hook, #mayHuman(s, verb, target). Until row 193
lands, the hook passes for the business's one human, which is the owner. The verbs are listed once, in
an exported LIFECYCLE_HUMAN_VERBS (instance.launch, instance.end, instance.regenerate, veto.set,
and instances.set ops add, change, delete), so row 193 maps each verb to the human roles that may
use it. Row 193 replaces the hook body and nothing else.
D4.1 The tester (Sage, sage-1355)
What the tester may do after this row:
| Verb | Tester |
|---|---|
instance.launch, instance.end |
yes, new |
instances.set ops add, change |
yes, as today (row 175, broker.mjs:103, serve.mjs:537) |
instances.set op delete |
no, tester-refused in the broker (B6) |
instance.regenerate, veto.set |
no, tester-refused |
The tester's cap is bound to one business, and only when that business sets bus.tester to true
(broker.mjs:542-546). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester
lifecycle call, before any effect, and again at the take (D1.1):
- The request's business is the cap's business. Any other refuses
tester-refused. bus.testeris exactly true in the business file as it stands, read throughsettings.current(id)asharnessOndoes (runtime.mjs:136-142), not from the broker's copy (B8). An unreadable file refusestester-off. So a hand edit turning testing off counts at the next tester call, without a restart.
The request and done events record actor tester. The tester's Q173 tests of the Agents section then cover
launch and terminate on a scratch business, and the rest through fixtures.
D5. One stack instance
Row 190 carries the stack instance as the config MOSAIC_CONFIG names. These verbs take no instance name.
The CLI reaches the broker socket under that config's dataRoot. The broker acts on its own businesses,
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
the other byte-identical (T11).
D6. The kill switch
- Launch and regenerate end in
start(), which row 191 gates (its D3 inventory,launcher.mjs:329-457). The new pass adds no second start path. - The broker refuses
instance.launchandinstance.regeneratewithprocessing-haltedwhile the switch is on (V2 step 1), and the take checks again (D1.1), so no request is waiting when a human resumes. instance.hireandinstance.fireare agent work verbs, so row 191's default refuses them while halted. The build adds them to row 191's work table, not its read list. A hire Jason resolved before a halt still refuses while halted, and the chief calls it again after the resume.- Terminate, create, delete and veto start nothing and stay allowed.
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted checks are written against its reader and tested with its fixture.
D7. The Console
A new Settings section, "Agents", in its own agents.js file. Settings itself isn't rewritten. It lists
each instance with definition, listed, kept, retired and running runs, and flags a run with a
lifecycle.end.alive event as "still running after fire" (D2.2). It has these actions:
- Create (definition select, from
definitions), Delete (with a confirm naming what stays: the evidence and the workspace under.retired). - Launch, Terminate (with an optional reason), Regenerate (with a confirm).
- Keep and Unkeep per instance, and a No-hire toggle per definition.
The routes are POST /api/instances (op delete joins add and change), POST /api/agent/launch,
/api/agent/terminate, /api/agent/regenerate and POST /api/veto. Each uses instanceSend's boundary:
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
through the human transport, and outcome-unknown on an unexpected answer. Launch and regenerate show the
request, then poll GET /api/agent/request?id= until it closes.
agents.js joins the static file list (serve.mjs:28) and index.html (N5).
When /api/config says send is false, every action button shows as unavailable with the text "Agent
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
route gets a --send exception.
D8. Tests
Every test uses scratch roots under TMPDIR and the existing fixtures (business/tests/helpers.mjs,
cli/tests/helpers.mjs, webui/tests/tester-fixture.mjs). None touches ~/.mosaic-dev,
~/.config/mosaic-dev, a live unit or a slot file.
| Test | Shows | File |
|---|---|---|
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says restart: false |
cli instances.test.mjs |
| T2 | V2: a human launch of an unlisted instance runs. launch.by goes through the pm branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused check() closes the request with its code |
cli lifecycle.test.mjs (new) |
| T2a | Take: a launch.revoke between the request and the pass closes it launch-revoked with no process. A lifecycleDone that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice |
same |
| T3 | V2 with --no-launch and with each limit refusal: no process, request closed with the code |
same |
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled | same |
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request regenerate-timeout with nothing started. The assistant refuses regenerate-thread |
same |
| T6 | V5: each refusal in the V5 table, in order, including instance-starting with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds retired, and a create or hire of the same name refuses. A failed rename closes workspace-rename-failed, check() refuses the name, and a second delete renames only |
business instance-settings.test.mjs and cli lifecycle.test.mjs |
| T7 | V6: each op, the no-op refusal, an unknown noHire name accepted, and a changes.jsonl entry with actor human |
business |
| T8 | Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses role-not-chief. Outside hireable, in noHire, credentialed, END_PROTECTED, retired, and extra vars each refuse |
bus chief-of-staff.test.mjs |
| T8a | Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses organization-instance-exists |
same |
| T8b | Hire allowlist (D2.3): one case per action in ACTIONS that is neither on HIRE_ALLOWED nor a hard wait (role.mjs already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in hireable, is rewritten to hold that action before the hire. The hire refuses definition-not-hireable. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes HIRE_ALLOWED to the six actions |
same |
| T8c | Allowlist at load: a role file whose hireable names a definition holding each refused action refuses at loadRole, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses |
business role.test.mjs |
| T9 | Chief fire: runs end, open requests cancelled, delisted, still in roles. Kept (even with a resolved decision), END_PROTECTED, launch.by and last-listed refuse |
bus chief-of-staff.test.mjs |
| T9a | Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has lifecycle.end.killed with its run and pid. A stand-in whose kill is stubbed to fail gets lifecycle.end.alive. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL |
cli launcher.test.mjs |
| T9b | Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason fire and the decision, and counts the run in its totals. A fire while halted refuses (D6) |
cli launcher.test.mjs, on row 191's fixture |
| T9c | Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses decision-consumed. With C stubbed to fail, the second fire finds I delisted and only consumes |
bus chief-of-staff.test.mjs |
| T10 | Keep: chief role.revoke with a reason, fire, hire of that name and an organization.set model write aimed at a kept instance each refuse instance-kept. A role.revoke citing a human-resolved decision ends the kept run (Q2). The human's V3 works |
same |
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical. A chief of business A can't hire into business B on the same bus | cli lifecycle.test.mjs, bus |
| T12 | Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including tester-refused for instances.set op delete (B6). The D4 hook is called with the verb |
bus instances-verb.test.mjs |
| T12a | Tester launch and terminate work on the cap's business with bus.tester true, record actor tester, and refuse tester-refused for a second business on the same bus. A file edit setting bus.tester to false, with no restart, refuses the next call and the take tester-off. An unreadable file refuses tester-off |
bus instances-verb.test.mjs |
| T13 | organization.set with launch, retired, keep or noHire keys refuses |
bus |
| T14 | Live reload: after each committed verb, plugins.set and routes.set included, the broker answers from the new document. A definition change on a running instance refuses instance-running |
bus |
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse processing-halted at the verb and at the take, hire and fire refuse, terminate works |
bus and cli |
| T16 | Console: each route's boundary refusals, a fixture write per action, outcome-unknown on a bad shape, unavailable buttons when send is false, request polling, and agents.js served from the static list |
webui agents.test.mjs (new) |
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business business.test.mjs |
| T18 | The forbid can't be changed by the chief (D2.4): from a chief session, veto.set, organization.set with each list key, instances.set, and hire and fire with extra fields all refuse, and the business file bytes are unchanged |
bus chief-of-staff.test.mjs |
Mutants
Each one must fail at least one test. They run against the candidate, and the file is restored and its sha256 checked after each one.
| # | Change | Killer |
|---|---|---|
| M1 | instance.launch skips #human |
T12 |
| M2 | the pass calls check() without human: true |
T2 |
| M3 | the take accepts a request older than 60 s | T2 |
| M4 | the take skips the launch_state check |
T2a |
| M5 | terminate signals without the start-time check | T4 |
| M6 | regenerate starts before the old exit | T5 |
| M7 | delete skips the running check | T6 |
| M8 | delete skips retired |
T6 |
| M9 | delete clears keep instead of refusing |
T6 |
| M10 | delete removes the workspace instead of renaming it | T6 |
| M11 | the keep check is removed from the role.revoke reason branch |
T10 |
| M12 | noHire is not read by hire |
T8 |
| M13 | fire deletes the instance from roles |
T9 |
| M14 | reloadBusiness isn't called after instances.set |
T1, T14 |
| M15 | the tool enum is kept | T8 |
| M16 | the halted check is dropped from instance.launch |
T15 |
| M17 | the Console route skips the Origin check | T16 |
| M18 | veto.set joins TESTER_ORGANIZATION_VERBS |
T12 |
| M19 | the tester's business scope check is dropped | T12a |
| M20 | the tester's bus.tester file read is dropped |
T12a |
| M21 | hire skips the role file's hireable list |
T8 |
| M22 | the take doesn't write lifecycle.taken, so a request can be taken twice |
T2a |
| M23 | terminate doesn't cancel open requests | T4 |
| M24 | delete skips the instance-starting check |
T6 |
| M25 | delete renames before it commits the file | T6 |
| M26 | the tester's instances.set op delete refusal is dropped |
T12 |
| M27 | the keep check is dropped from organization.set's model write |
T10 |
| M28 | the keep check is added to the role.revoke decision branch |
T10 |
| M29 | a re-hire accepts a different definition | T8a |
| M30 to M36 | the broker's hire check accepts one action outside HIRE_ALLOWED, one mutant per action: role.launch, role.end, organization.change, instance.name, review.verdict, git.merge.protected, decision.resolve.technical |
T8b |
| M37 to M43 | the same seven, accepted by the load check in role.mjs only |
T8c |
| M44 | fire skips instance-kept when a decision is cited |
T9 |
| M45 | instance.hire skips the launch.by caller check |
T8 |
| M46 | veto.set skips #human |
T18 |
| M47 | the broker's hire check needs some action on HIRE_ALLOWED instead of every action |
T8b |
| M48 | the same in role.mjs only |
T8c |
| M49 | fire revokes the claims but writes no lifecycle.end, so no runner is signalled |
T9a |
| M50 | fire's signal skips spawned but unbound registry runs | T9a |
| M51 | fire's stop drops the SIGKILL escalation | T9a |
| M52 | SIGKILL is sent without the second identity check | T9a |
| M53 | a killed runner gets no lifecycle.end.killed |
T9a |
| M54 | V3 terminate escalates to SIGKILL as fire does | T9a |
| M55 | the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping | T9b |
| M56 | fire delists before the store step | T9c |
| M57 | fire consumes the decision in step A | T9c |
| M58 | a second fire with nothing to revoke refuses not-held |
T9c |
Scope and boundaries
- In the row: the verbs and passes above in
business(vocabulary, role validation, instance-settings, business validation),bus(broker verbs, reload, runtime callback),cli(instances command, launcher pass, host ops),harness(the launch tool's schema, and the hire and fire tools),webui(routes and the Agents section), the tests, the package READMEs and the PRD draft in this packet.docs/TOOLS.mdtext goes to Sage. - Named for review:
- Vocabulary:
role.hireandrole.firejoinACTIONSas hard waits, under REQ-WAIT-3. The commit message names them as hard waits, as REQ-WAIT-3 requires. - Role schema: the optional
hireablekey, validated inrole.mjs(D2.3). - Role file:
roles/chief-of-staff.jsongains"hireable": []. No authority change. - Business file schema:
launch.keep,launch.noHire,retired(D2.5).
- Vocabulary:
- Not in the row: the routed form (after PRD 1.7), filling
hireable, credentialed hires and deletes (row 199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any live activation or Console start.
Risks
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed roles until the credentials row (row 199). That is the largest gap against Case 1, and it isn't this row's to close.
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
human-cli.mjscan launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no check of its own. - R3. The 60 s request window means a slow
check()(checkouts) can still start a run up to that point after the CLI gave up. The CLI says so on timeout. - R4. Rollback past this row needs
keep,noHireandretiredremoved from each business file, andhireablefrom the role file, or the old loaders refuse them. The activation plan states this. - R5.
tools.mjs,serve.mjs,broker.mjs,vocabulary.mjsandrole.mjsmay carry sha256 pins in other packages' tests. The build greps the tests for every changed path plus a 64-hex value before in-review, without ahead. - R6. With
hireableempty, the chief can hire nothing until the policy commit inroles/that follows theprd.approveresolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7. - R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL
(an unkillable process, or a stop whose identity check fails) keeps the shared model token until the
human acts on
lifecycle.end.alive. Only a per-agent model token (row 199 Q-M4, a later proxy row) would let a fire revoke the token itself.
Questions and rulings
- Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365):
role.hireandrole.fireship as hard waits Jason resolves. The routed form needs PRD 1.7, drafted inPRD-1.7-DRAFT.md. D2.1. - Q2, ruled by Webby (webby-0003): keep blocks the chief's
role.revokereason branch, not its decision branch. D2.4. - Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own
business, behind
bus.tester. D4.1. - Q4, ruled by Sage (sage-1374):
hireablestays empty in this row. Naming the first role kind is a separate policy commit inroles/after Jason resolves theprd.approvedecision. Tests use scratch role files that name a kind. D2.3.
r2 changes against webby-0003
| Finding | Where |
|---|---|
| B1 | D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft) |
| B2 | D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4) |
| B3 | D2.2 hire item 5: a re-hire keeps the definition, T8a, M29 |
| B4 | D1.1: one lifecycleTake transaction re-checks and writes lifecycle.taken, T2a, M4, M22 |
| B5 | V3 cancels open requests and signals unbound runs. V5 refuses instance-starting, runs under serialize, commits the file before the rename, and states the recovery. T4, T6, M23 to M25 |
| B6 | V5 table and D4.1: the tester's delete refuses, T12, M26 |
| B7 | D2.4: keep covers organization.set's model write, T10, M27 |
| B8 | D4.1: bus.tester is read from the file at each call and at the take, T12a, M20 |
| Q2 | D2.4, T10, M28 |
| N1 | D3 item 2: one reload after every committed write |
| N2 | D2.5: noHire takes any id-pattern name |
| N3 | D2.2: hire's vars limited to the model key and narrowing limits.* |
| N4 | D3 item 4 names the tests that change |
| N5 | D7 and T16: agents.js joins serve.mjs:28 and index.html |
| N6 | V3 and V4: no SIGKILL, regenerate-timeout |
| N7 | D2.4: intended, Q169 |
| N8 | V2 item 2 and T2 |
r3 changes against sage-1374, sage-1377 and Megavolt's F4
| Item | Where |
|---|---|
Q4 ruled: hireable stays empty, the first kind is a later policy commit in roles/ |
D2.3, R6, Questions |
instance.name joins the floor (Webby B2, mos-1023 item 6) |
D2.3 table, T8b, T8c, M47, M48 |
F4: fire ends the fired agent's processes, since ENV_ALLOW gives every run the shared model token |
D2.2, D7, T9a, M49 to M51, R7 |
git.push.remote routed to coder only (mos-1028 item 3, row 199 r3) |
PRD-1.7-DRAFT.md, REQ-LAND-2 and the REQ-WAIT-3 append |
r4 changes against webby-0010, sage-1380 and megavolt-0008
| Item | Where |
|---|---|
webby-0010 B1: the deny-list floor becomes the allowlist HIRE_ALLOWED, so merge-gate, decision.resolve.technical, policy.change, prd.approve and any later action are refused |
D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound |
| webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse | D2.1 item 3 |
| webby-0010 N2: the model write is in REQ-COS-3's text | PRD-1.7-DRAFT.md |
| sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed | D2.2, T9a, T9b, M51 to M55, R7 |
sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) |
PRD-1.7-DRAFT.md |
| webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants | D2.3 |
| webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat | D2.2 order A to C, T9c, M56 to M58 |
| webby-0011 N1: the remote-owner host check at each push | sent to Megavolt for row 199 |
| webby-0011 N2: identity, not bare pid, at every check | D2.2 step 3 |
| Q4: Webby's view matches Sage's ruling (empty) | no change |