Compare commits

...
10 changed files with 434 additions and 23 deletions
+74
View File
@@ -0,0 +1,74 @@
# Row 256 (#1761) build notes (append only)
## 2026-10-22 b1 (Honker)
Design d2 8c50481d2 was accepted by Webby (webby-0123). The code is on honker/row256 from be2b008cc.
### Correction to the design: the non-creative END_PROTECTED definition is `assistant`
- DESIGN.md (d1 and d2) says "the chief of staff" for the END_PROTECTED definition that isn't creative,
following the brief's wording. In the code that definition is `assistant`:
- `THREAD_DEFINITION = "assistant"` (vocabulary.mjs:203).
- `END_PROTECTED = THREAD_DEFINITIONS = [assistant, ...CREATIVE_DEFINITIONS]`.
- The `chief-of-staff` definition (the `chief` instance) isn't END_PROTECTED. Its delete refuses
`organization-instance-referenced`, because it is launch.by, and that is unchanged.
- So T3 and M2 use an `assistant` instance. The code is the same either way: protectedRefusal refuses
every END_PROTECTED definition that isn't creative.
- DESIGN.md isn't edited after approval; this note is the record.
### Persona file on delete (brief item 4)
- `<configDir>/personas/<business>/<instance>.md` is left in place. Nothing removes it, so there is no
new open and no O_NOFOLLOW test.
- Nothing reads it once the entry is gone. bundle.mjs reads it only for a launched creative instance,
and the name is retired, so `add` refuses it (instance-settings.mjs, organization-instance-retired)
and no later instance picks up the old persona.
- The human removes the file by hand if they want it gone.
### What changed
- packages/business/src/instance-settings.mjs:
- New `protectedRefusal(document, instance, actor)`.
- `deleteRefusal(..., actor)`, `planDelete(..., actor)` and `checkDelete(..., actor)` take the
actor, and set op delete passes its actor.
- `read(business, actor)`. Its `endProtected` is `protectedRefusal(...) !== null`.
- commit's and fire's returned listings read with the writer's actor.
- The comment at :28-31 is rewritten.
- packages/bus/src/runtime.mjs: `instances` and `instances.check` pass the actor. There is no broker
change.
- packages/webui/src/public/agents.js: comment only.
- docs/plans/DEFERRED.md: a closing line on the creative-delete entry.
- Tests:
- business instance-settings.test: T1-T3 and T5 in one test, T4 in its own test, and the row 213
test's `endProtected` for the human's add is now `false`.
- business lifecycle-settings.test: checkDelete with the actor; fire unchanged.
- bus creative.test: T6 on the real runtime.
- webui agents-browser.test: T7/T8. The comment on the existing assertion is reworded.
### Mutants for the gate (exact-once replacements, runb8 shape)
- M1 (instance-settings.mjs): ` if (actor === document.human) return null;` becomes ` return null;`.
The kill is instance-settings.test "row 256 T1-T3".
- M2 (instance-settings.mjs): move the human check ahead of the creative one. Replace
` if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };\n if (actor === document.human) return null;`
with
` if (actor === document.human) return null;\n if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };`.
The kill is "row 256 T1-T3" (T3).
- M3 (instance-settings.mjs): ` endProtected: protectedRefusal(document, name, actor) !== null,` becomes
` endProtected: END_PROTECTED.includes(entry.definition),`. The kills are "row 256 T1-T3" (T5) and
bus creative.test T6.
- M4a (runtime.mjs): `instances.checkDelete(id,args.instance,actor)` becomes
`instances.checkDelete(id,args.instance)`. The kill is creative.test "row 256 T6".
- M4b (instance-settings.mjs): `planDelete(old, instance, actor), { revision` becomes
`planDelete(old, instance), { revision`. The kills are "row 256 T1-T3" (T1) and creative.test T6.
### Tests not run
None of these have run. They wait for Sage's CLEAR, after the rebase onto 236.
## 2026-10-22 b2 (Honker): Webby's N1 (webby-0124)
- Two comments said the chief of staff where they mean the assistant. Both now name the assistant:
- instance-settings.mjs:30, "The assistant is refused for every actor, the human included."
- agents.js:46, "true for the assistant and on the tester's."
- Comment-only. The mutant strings above are unchanged and still occur once.
+186
View File
@@ -0,0 +1,186 @@
# Row 256 (#1761) design: the human deletes a creative instance
Brief: docs/plans/2026-10-21_creative-delete-human.md (REQ-COS-3). Base be2b008cc (row 194 landed as
fd6dd327c). Builder Honker, reviewer Webby. Draft 2, 2026-10-22: Webby's d1 review (webby-0122, notes
171480e6e) R1, Q1, Q2 and N1-N2 applied; the changes are listed at the end.
## What changes
### 1. One predicate in instance-settings.mjs
Today `deleteRefusal(document, rolesDir, instance)` refuses every END_PROTECTED definition with
`organization-definition-refused`, and read()'s `endProtected` field is the same check
(`END_PROTECTED.includes(definition)`).
New, in `packages/business/src/instance-settings.mjs`:
```js
// Row 256 (#1761, REQ-COS-3): the protected-definition part of a delete. A creative instance is
// the human's to delete; any other actor is refused. A non-creative END_PROTECTED definition (the
// chief of staff) is refused for every actor, the human included.
function protectedRefusal(document, definition, actor) {
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
return actor === document.human ? null : { code: "organization-creative-human-only" };
}
export function deleteRefusal(document, rolesDir, instance, actor) {
...unknown-instance unchanged...
const refusal = protectedRefusal(document, entry.definition, actor);
if (refusal) return refusal;
...references, kept, credentials unchanged...
}
```
- A creative definition with `actor === document.human` goes on to the remaining checks:
references, then kept, then credentials.
- The code and message match `creativeHumanOnly` in launch-settings.mjs:13.
- The code is `organization-creative-human-only`.
- It is raised as a plain refusal code here, as deleteRefusal's other codes are.
- I don't call `creativeHumanOnly` itself, because it throws and deleteRefusal returns.
- `actor` undefined, null, "tester", or a role actor (`<definition>.<instance>`) never equals
`document.human`, so those actors are refused.
- validateBusinessDocument runs `requireId(document.human)` (business.mjs:239), so an undefined
actor can't match an undefined human.
### 2. The actor reaches every delete path
| Path | Today | Change |
| --- | --- | --- |
| `planDelete(document, instance)` | no actor | `planDelete(document, instance, actor)` |
| `set(business, args, actor)` op delete (:207) | has the actor, doesn't pass it | passes it to planDelete |
| `checkDelete(business, instance)` | no actor | `checkDelete(business, instance, actor)` |
| runtime.mjs:283 `instances.check` | `checkDelete(id, args.instance)` | `checkDelete(id, args.instance, actor)` |
- The broker's verb check `#deleteRequest` already passes `s.human` to `instances.check` (broker.mjs:2843).
- The launcher pass `lifecycleDelete` already passes the request's `r.by`, which is
`#owner(s)` at record time, to `instances.set op delete`.
- So the broker gets no logic change. Only the deferred comment at :2825 is touched, if it names
the old rule.
- The CLI (`mosaic instances delete`) and the Console both reach the same bus verb with the human's
session.
- The tester is refused `tester-refused` at the verb, before any of this (broker.mjs:2595,
serve.mjs:394).
- That is unchanged.
### 3. The listing: `endProtected` becomes the per-viewer delete check
- `read(business)` becomes `read(business, actor)`, and runtime.mjs passes the actor.
- The broker already passes `"tester"` for a tester session and `s.human` otherwise (broker.mjs:2627).
- Each instance's `endProtected` becomes `protectedRefusal(document, def, actor) !== null`.
- This is the same server predicate the delete runs, per the brief.
- The human's read shows `endProtected: false` for a creative instance.
- The tester's read, and any role read, show `true`.
- The chief of staff shows `true` for everyone.
- The listing that a write returns is read with the writer's actor.
- This covers `commit`'s `{ ...read(business), ... }` at :405 and fire's at :338.
- So the human's add of bean returns `endProtected: false`, and the existing :288 assertion
changes from `true` to `false` for "jason".
- I keep the field name, so the listing gains no key and the deepEqual shapes in
instance-settings.test, lifecycle-settings.test and agents-browser.test keep their keys.
- Only the value for a creative entry read by the human changes.
- The cost is a name that now means "delete refused for its protected definition, for this
viewer".
- The read() comment says so.
- A rename (for example `deletable`) touches every listing fixture; I'd rather not, but I'll take
Webby's call.
- agents.js:46 is unchanged in logic: `if (!e.endProtected)` offers Delete.
- Its comment changes to name row 256.
- Delete stays inside the existing `!c.isTester` branch, so the tester page offers none whatever
the field says.
- The tester has two guards: the server's `true`, and the page's tester branch.
### 4. Fire is unchanged
- `fireChecks` / `checkFire` / `instance.fire` / `role.fire` keep refusing END_PROTECTED for
every actor.
- protectedRefusal is not called from fire.
- A test pins that the human's fire of a creative instance still refuses.
### 5. Persona file
- `<configDir>/personas/<business>/<instance>.md` is left in place on delete.
- That is the brief's smallest choice.
- The delete commits the business file and renames the workspace under `.retired`, as today.
- The persona file isn't under either path, and nothing reads it once the entry is gone.
- bundle.mjs reads it only for a launched creative instance.
- Retired names are never reused (row 194 D1), so a later hire can't pick up the old persona.
- This goes in BUILD.md, plus a DEFERRED line if Sage wants a cleanup later.
- No new file open, so no O_NOFOLLOW test.
### 6. DEFERRED.md
The "Creative instances: the human can't delete one" entry (DEFERRED.md:387) gets a closing line:
> Closed by row 256 (#1761) for delete. Fire still refuses, by REQ-COS-3.
The entry says "the row covers fire and delete together". The brief keeps fire unchanged, so the
closing line says the fire half is closed by ruling, not by code.
## Tests
| File | Test |
| --- | --- |
| business/tests/instance-settings.test.mjs | T1: the human deletes a creative instance (bean); the file loses the entry. |
| | T2: the same delete by a role actor (`chief-of-staff.chief`), by "tester" and by no actor refuses `organization-creative-human-only`, and the file is unchanged. |
| | T3: the human's delete of the chief of staff refuses `organization-definition-refused`. |
| | T4 (R1): with bean the only name in launch.instances, the human's delete of bean gets the last-listed BusinessError from deletedDocument (:64-66), not the creative code, so it passes protectedRefusal and reaches the later checks. A creative instance can't be in launch.keep, launch.by or the arbiters (business.mjs:208, :181-183, :251-255), so those aren't used. |
| | T5: read(business, human) shows `endProtected: false` for bean; read(business, "tester") and read(business) with no actor show `true`; the chief of staff shows `true` for every actor. |
| | The existing :283-292 test is updated where it pinned the old refusal. |
| business/tests/lifecycle-settings.test.mjs | checkDelete with the human passes for a creative instance and with another actor refuses. |
| | The fire of a creative instance by the human still refuses (fire unchanged). |
| bus/tests/creative.test.mjs (Q2: its runtime() is a real startBroker with the business writer) | T6: the human's `instances.set op delete` on bean records a lifecycle.delete request. |
| | Taking it and running the launcher pass commits the delete (the r.by path). |
| | The chief is refused `human-required` (broker.mjs:2585) and the tester `tester-refused` (:2595), both at the verb. |
| | The human's `instances` read has `endProtected: false` for bean; the tester's has `true`. |
| webui/tests/agents-browser.test.mjs | T7: the human page with bean at `endProtected: false` offers Delete for bean. |
| | T8: the tester page with the same listing offers no Delete. |
| | The existing sage-2164 assertion moves to the tester-shaped listing (bean `endProtected: true`). |
- The browser tests stub `/api/instances`, so they pin the page's use of the field.
- The server side of "never on the tester listener" is T5/T6.
- No test touches live paths: fixtures under mktemp in /mnt/storage/scratch, as today.
## Mutants (all must be killed)
- M1: protectedRefusal's creative branch returns `null` for every actor, so a non-human deletes a
creative instance. Killed by T2 only (N1): through the bus no non-human actor reaches the delete,
since the chief and the tester are refused at the verb.
- M2: protectedRefusal checks the human before the creative check, so
`actor === document.human ? null : ...` for every END_PROTECTED definition. The human then
deletes the chief of staff. Killed by T3.
- M3: read() keeps the old `END_PROTECTED.includes`, so the listing isn't the per-viewer predicate.
Killed by T5.
- M4 (N2): the actor isn't threaded: runtime.mjs:283 calls `checkDelete(id, args.instance)`
without it. Killed by T6, the human's real-writer delete. A second variant, set op delete not
passing the actor to planDelete, is killed by T1 and by T6's launcher pass.
## Boundaries kept
- No change to M10 add/change, role.hire, fire, veto or launch.keep.
- No schema change and no migration.
- No new verb.
- No change to the tester gate.
## Gate (on Sage's CLEAR)
- Touched test files, each whole.
- The business, bus, cli and webui suites.
- Every scripts/test-*.sh.
- The four mutants above (M4 in both variants), each on a green base, restored and blob-checked (the runb8 shape).
## Comments rewritten (Q1)
- `endProtected` keeps its name: agents.js:46 is its only reader, and it means "don't offer Delete".
- instance-settings.mjs:28-31 loses "no one deletes one yet". It says the human deletes a creative
instance and any other actor is refused, and that the listing's endProtected is protectedRefusal
for the reader.
- agents.js:45 says the same, naming row 256.
## Changes from draft 1 (webby-0122)
- R1: T4 uses the last-listed refusal instead of launch.keep, which the validator refuses for a
creative instance.
- Q1: keep the name; comments rewritten; T5 pins read(business) with no actor as true.
- Q2: the bus test goes in creative.test.mjs.
- N1: M1 is killed by T2 only; T6 pins the chief's `human-required`.
- N2: M4 added for the threading.
+6
View File
@@ -396,6 +396,12 @@ at every gate. Started 2026-09-12 during the control board MVP.
(instances.set launch: false) but neither fire nor delete removes a
creative entry (sage-2166). The row covers fire and delete together;
Sage files it. (2026-10-21)
Closed for delete by row 256 (#1761, REQ-COS-3): `deleteRefusal` takes
the actor, so the human deletes a creative instance and any other actor
is refused `organization-creative-human-only`, and the listing's
endProtected is the same check for its reader. Fire still refuses every
END_PROTECTED instance, by the row 256 brief's ruling, not by a code
change. (2026-10-22)
- **A fire stuck between step A and step B has no human escape.** (Webby
N4 on row 194 b5, webby-0094; Sage's ruling sage-2241, #1692.) The
broker's chief fire records `fire.stopped` at step A, and `#firing`
+3 -2
View File
@@ -283,11 +283,12 @@ export async function startBroker({
organization:settings?(id,verb,args,actor,context)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return plugins.read(id);
if(verb==='instances')return instances.read(id);
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
if(verb==='instances')return instances.read(id,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
let result;
+28 -1
View File
@@ -123,7 +123,7 @@ async function runtime(t) {
const history = join(root, 'businesses/.history/demo/changes.jsonl');
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
const bytes = () => readFileSync(file, 'utf8');
return { human, tester, chief, call, log, bytes };
return { bus, human, tester, chief, call, log, bytes };
}
test('M10 through the broker: the chief and the tester are refused on bean, the human\'s limits.set of its models key works', async (t) => {
@@ -151,3 +151,30 @@ test('M10 through the broker: the chief and the tester are refused on bean, the
assert.equal(JSON.parse(bytes()).launch.models['claude-sonnet-5-5'].model, 'claude-sonnet-5-5-next');
assert.deepEqual(log().filter((e) => e.state === 'committed').map((e) => e.actor), ['jason']);
});
// Row 256 (#1761, REQ-COS-3, T6): the human deletes bean through the verb and the launcher's pass, on
// the real writer, so the actor reaches the delete check at both. The chief is refused human-required
// and the tester tester-refused at the verb, so neither reaches the writer. Each reader's instances
// view carries its own endProtected.
test('row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb', async (t) => {
const { bus, human, tester, chief, call, log, bytes } = await runtime(t);
assert.equal(call(human, 'instances').instances.bean.endProtected, false);
assert.equal(call(tester, 'instances').instances.bean.endProtected, true);
const { revision } = call(human, 'instances');
const before = bytes();
assert.throws(() => call(chief, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'human-required' });
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'tester-refused' });
assert.equal(bytes(), before);
// The verb's check runs instances.check with the human as actor, and records the request.
const { request } = call(human, 'instances.set', { revision, instance: 'bean', op: 'delete' });
assert.equal(bytes(), before);
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
// The launcher's pass commits the delete with the request's by, the human.
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
const file = JSON.parse(bytes());
assert.equal(Object.hasOwn(file.roles, 'bean'), false);
assert.deepEqual(file.retired, ['bean']);
const committed = log().filter((e) => e.state === 'committed');
assert.deepEqual(committed.map((e) => e.actor), ['jason']);
assert.equal(committed[0].transaction, transaction);
});
+28 -16
View File
@@ -25,15 +25,25 @@ const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(v
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
// The last-listed check runs when the proposed file is validated (deletedDocument). A creative
// definition is END_PROTECTED too (row 213), so no one deletes one yet (sage-2164, DEFERRED). The
// listing's endProtected is this same check, so the Agents page offers no Delete it would refuse.
const endProtected = definition => END_PROTECTED.includes(definition);
export function deleteRefusal(document, rolesDir, instance) {
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
// change. The assistant is refused for every actor, the human included. The listing's
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
function protectedRefusal(document, instance, actor) {
const { definition } = document.roles[instance];
if (!END_PROTECTED.includes(definition)) return null;
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
if (actor === document.human) return null;
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
}
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance, actor) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
if (endProtected(entry.definition)) return { code: "organization-definition-refused" };
const refused = protectedRefusal(document, instance, actor);
if (refused) return refused;
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
@@ -157,7 +167,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return out;
}
function read(business) {
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
// no actor (the bus's reloads) answers as for any non-human.
function read(business, actor) {
const { document, revision } = writer.read(business);
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
const launch = document.launch ?? null;
@@ -168,7 +180,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
launcher: launch ? launch.by === name : false,
protected: closed(entry.definition),
kept: (launch?.keep ?? []).includes(name),
endProtected: endProtected(entry.definition),
endProtected: protectedRefusal(document, name, actor) !== null,
}]));
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
@@ -204,7 +216,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
const services = Object.keys(old.roles[instance]?.credentials ?? {});
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
const result = commit(business, old, planDelete(old, instance), { revision: args.revision, actor, beforeWrite });
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite });
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
// can't reach (row 199 A5).
const bot = old.roles[instance]?.tracker?.bot;
@@ -255,15 +267,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance) {
const refusal = deleteRefusal(document, rolesDir, instance);
function planDelete(document, instance, actor) {
const refusal = deleteRefusal(document, rolesDir, instance, actor);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance) {
function checkDelete(business, instance, actor) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance);
const next = planDelete(document, instance, actor);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
@@ -335,7 +347,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
@@ -402,7 +414,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
if (beforeWrite) beforeWrite();
const receipt = writer.write(business, next, { revision, actor, context });
return { ...read(business), transaction: receipt.transaction, restart: false };
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
@@ -285,7 +285,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
e => e.code === "organization-creative-human-only", `${actor} redefine`);
}
let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason");
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: true });
// Row 256: the listing a write returns is read for the writer, and the human may delete bean.
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: false });
for (const actor of ["chief-of-staff.chief", "tester"]) {
for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) {
assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor),
@@ -315,3 +316,65 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
assert.equal(st.mode & 0o777, st.isDirectory() ? 0o700 : 0o600, name);
}
});
// Row 256 (#1761, REQ-COS-3): the human deletes a creative instance and any other actor is refused
// with the creative code. The assistant, the END_PROTECTED definition that isn't creative, refuses
// every actor, the human included. The listing's endProtected is that same check for its reader.
test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor does, and the assistant stays", () => {
const f = fixture(doc => {
doc.roles.bean = { definition: "companion" };
doc.roles.muse = { definition: "ideas" };
doc.roles.assistant = { definition: "assistant", holder: "mos" };
});
const del = (instance, actor) => () => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance, op: "delete" }, actor);
const refused = (act, code, label) => {
const bytes = readFileSync(f.file, "utf8");
assert.throws(act, e => e.code === code, label);
assert.equal(readFileSync(f.file, "utf8"), bytes, label);
};
// T5: the reader's view. A read with no actor answers as for any non-human.
for (const [actor, bean] of [["jason", false], ["tester", true], ["chief-of-staff.chief", true], [undefined, true]]) {
const view = f.settings.read("acme", actor);
assert.equal(view.instances.bean.endProtected, bean, `bean ${actor}`);
assert.equal(view.instances.assistant.endProtected, true, `assistant ${actor}`);
assert.equal(view.instances.coder.endProtected, false, `coder ${actor}`);
}
assert.equal(f.settings.read("acme").instances.bean.endProtected, true);
// T2: every actor but the human gets the creative code, and the message says why.
for (const actor of ["chief-of-staff.chief", "tester", "mos", undefined]) {
refused(del("bean", actor), "organization-creative-human-only", String(actor));
assert.throws(() => f.settings.checkDelete("acme", "bean", actor), e => e.code === "organization-creative-human-only", `check ${actor}`);
}
assert.throws(del("bean", "tester"), /bean is a companion instance, which only the human deletes/);
// T3: the assistant refuses every actor, the human included.
for (const actor of ["jason", "chief-of-staff.chief", "tester"]) {
refused(del("assistant", actor), "organization-definition-refused", `assistant ${actor}`);
}
// T1: the human's delete.
assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.settings.read("acme").revision });
const done = del("bean", "jason")();
assert.equal(Object.hasOwn(done.instances, "bean"), false);
assert.deepEqual(done.retired, ["bean"]);
assert.equal(done.instances.muse.endProtected, false);
const file = JSON.parse(readFileSync(f.file, "utf8"));
assert.equal(Object.hasOwn(file.roles, "bean"), false);
assert.deepEqual(file.retired, ["bean"]);
assert.equal(f.log().at(-1).actor, "jason");
const run = f.validate();
assert.equal(run.status, 0, run.stderr);
});
// Row 256 (R1, webby-0122): a creative delete by the human goes on to the later checks. A creative
// instance can't be kept, launch.by or an arbiter, so the check it reaches is the last-listed one.
test("row 256 T4 the human's delete of the only listed instance, a creative one, is the last-listed refusal", () => {
const f = fixture(doc => {
doc.roles.bean = { definition: "companion" };
doc.launch.instances = ["bean"];
});
const bytes = readFileSync(f.file, "utf8");
const revision = f.settings.read("acme").revision;
assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "jason"), e => e.name === "BusinessError");
assert.throws(() => f.settings.checkDelete("acme", "bean", "jason"), e => e.name === "BusinessError");
assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "tester"), e => e.code === "organization-creative-human-only");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
@@ -229,3 +229,18 @@ test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
refuses(one, () => fire(one, "sec-1"), "BusinessError");
});
// Row 256 (#1761): checkDelete takes the actor, and fire is unchanged: a creative instance refuses
// every actor's fire, the human's included.
test("row 256 checkDelete passes the human's creative delete and refuses another actor's; fire still refuses", () => {
const f = fixture(doc => {
doc.roles.bean = { definition: "companion" };
doc.launch.instances.push("bean");
});
const bytes = readFileSync(f.file, "utf8");
assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.revision() });
for (const actor of ["chief", "tester", undefined]) refuses(f, () => f.settings.checkDelete("acme", "bean", actor), "organization-creative-human-only");
for (const actor of ["chief", "jason"]) refuses(f, () => f.settings.fire("acme", { name: "bean" }, { actor, context: CONTEXT }), "organization-definition-refused");
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
+2 -1
View File
@@ -42,7 +42,8 @@
if (!c.isTester) {
acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`));
acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep'));
// endProtected is the server's delete check, creative definitions included (sage-2164).
// endProtected is the server's delete check for this reader (row 256): false on the human's
// page for a creative instance, true for the assistant and on the tester's.
if (!e.endProtected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`));
}
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}${alive.map(r => ` <strong class="agent-alive">still running after fire: <code>${txt(r)}</code></strong>`).join('')}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`;
+28 -2
View File
@@ -40,8 +40,8 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true);
// The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false);
// sage-2164: a creative instance isn't protected (the human changes it), yet the server refuses its
// delete, so endProtected hides Delete for it too.
// A creative instance isn't protected (the human changes it). This listing marks bean endProtected,
// as a non-human reader's does, so the page hides its Delete; row 256's test below has the human's.
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="bean"]\')'),false);
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="launch"][data-agent-name="bean"]\')'),true);
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
@@ -123,3 +123,29 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
assert.equal(await b.evaluate('posts.length'),sent+1);
}finally{await b.close();await close(web);}
});
// Row 256 (#1761, T7, T8): the human's listing carries endProtected false for a creative instance
// (the server's delete check for that reader), so the human's page offers Delete for it. The tester's
// page offers no Delete even on that same listing; the tester's own listing marks bean endProtected
// as well (bus creative.test T6).
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
try{
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
const port=web.address().port;
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
const del=name=>`document.querySelector('[data-agent-action="delete"][data-agent-name="${name}"]')`;
// T7: the human's page.
await b.navigate(`http://127.0.0.1:${port}/?human=1#/settings`);
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')?.disabled===false`);
assert.equal(await b.evaluate(`${del('bean')}?.disabled`),false);
assert.equal(await b.evaluate(`!!${del('assistant')}`),false);
assert.equal(await b.evaluate(`!!${del('coder')}`),true);
// T8: the tester's page, on the same listing.
await b.navigate(`http://127.0.0.1:${port}/?tester=1#/settings`);
await wait(`!!document.querySelector('#tester-banner')&&!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')`);
assert.equal(await b.evaluate(`document.querySelectorAll('[data-agent-action="delete"]').length`),0);
}finally{await b.close();await close(web);}
});