Files

10 KiB

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:

// 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.