Lands Morgana's round 4 candidate,2b3e67e36(basec771a36f4, branch morgana/row213-r4), approved by Webby (webby-0071, REVIEW-r402bb0afb3, rev 2775). Manifest digest 26d4d0ecff56, 35 paths, plus Morgana's four packet files under agents/morgana/work/queue-213/ (base.txt, BUILD.md, candidate-manifest.sha256, mutants.py): 39 paths, blobs and modes from the candidate. No 213 path changed betweenc771a36f4and this commit's parent. Patch-id f313e69b6f7e, the same as c771a36f4..2b3e67e36. Evidence: Morgana's slot run on2b3e67e36(sage-1844): singles 191/0 and 31/31 mutants killed. Gate on my slot (sage-r195land, base38b83ea5cplus 195's 5 and 213's 39 paths, 09:45:52-09:57:52Z): business 200/0, bus 542/0, harness 140/0, cli 663/0; scripts auth 15/0, conductor 17/0, config 31/0, discord 78/0, extension-package 18/0, foundation 44/0, land-work 86/0, queue 156/0 plus 28/0, release 14/0, task 98/0; the models mutant L1 killed (launch-settings 15/1), restore matched. Between the gate base38b83ea5cand4364dddb0only docs/plans/2026-10-20_host-credentials.md changed (01e1fe512,4364dddb0), which no test reads. Row 195 landed as this commit's parent; it shares no path with 213. Webby's N2 (a host grant for a creative role) is a row 235 acceptance item (mos-1163), not a deferral.
18 KiB
Row 213 (C2-08) build: creative roles, persona file and block, M1, M2, M6, M10, prompt by file
Morgana (Opus 5.5), 2026-10-20. Queue row 213, Gitea #1714. Brief: docs/plans/2026-10-20_case2-rows.md
§ C2-08 (on refactor from 32b54ac3b). Design: Gosalyn's Case 2 design r10, sections 4 and 5.
Reviewer Webby, gate owner Sage. Base: base.txt (088087ac9). Manifest: candidate-manifest.sha256.
D1 and D2 approved as deviations in sage-1492.
What it is
- Six creative roles (
roles/<name>.jsonand.md, roleVersion 2): companion and research get read and web, with network api-only. ideas, proofreader, fact-checker and continuity get read only, with network none. All six get withinRole["message.send"], no credentials, and messaging that sends to and receives fromhumanonly.vocabulary.mjsnames them inCREATIVE_DEFINITIONS, plusCREATIVE_KINDS,CREATIVE_TOOLSandTHREAD_DEFINITIONS.END_PROTECTEDbecomesTHREAD_DEFINITIONS. - M1 (
role.mjscheckCreative): a creative role file is refused if it names a tool outsideCREATIVE_TOOLS, holds any authority but withinRolemessage.send, lacksmessaging, names anyone buthumanin messaging, or is version 1. - M2: since the role files name no write, edit or bash tool, the existing gate refuses write, edit and bash on pi and Write, Edit and Bash on claude-code for each creative role. No gate code changed. The test pins it.
- M6:
tools.mjsleavesraise_decisionout for a creative definition.broker.mjsrefusesdecision.raisefrom a creative session withdecision-not-allowed, after#secretCheckand before the spec-cited path, so a cited raise is refused too. - M10: a creative instance belongs to the human.
launch-settings.mjscreativeHumanOnlygivesorganization-creative-human-onlyto any other actor. It covers: amodelskey that a creative instance'smodelorfallback.modelvar names (changed or removed),setRoutesfor the instance, and theinstanceskey. Theinstanceskey is refused for every actor withorganization-creative-instance, because the model changes throughinstances.setorroutes.set.instance-settings.mjs: add, change and redefine to or from a creative definition are the human's. A creative definition isEND_PROTECTED, so the newclosed()lets this writer still add and change one.acceptedandprotecteduseclosed()too, so the human's Console form offers the creative definitions and marks those instances as editable.
- Persona (design 4): vars
persona.name(one line, max 40) andpersona.sha256(64 lowercase hex), agent layer. The file lives at<configDir>/personas/<business>/<instance>.md.bundle.mjsreadPersonaopens it with O_NOFOLLOW and refuses withpersona-invalidwhen the file is missing, a symlink, not a regular file, owned by another uid, carries group or other mode bits, is over 8000 characters, is not UTF-8, or holds</persona-file>or a line starting#or##.- It refuses with
persona-mismatchwhen the bytes don't match the digest. - A file present with no
persona.sha256var is ignored. A digest with nopersonaDirrefuses. - The block sits between the contract and
## This session, wrapped in<persona-file instance=... sha256=...>.
- Web:
webPlanrefusesweb-source-not-allowedwhen a creative definition is onharness-builtin, so every creative fetch goes through the broker. - Prompt by file: claude passes
--system-prompt-file <path>, and pi passes--system-prompt <path>. Both make the path absolute. The prompt text no longer appears in/proc/<pid>/cmdline.adapters/README.mdsays so.
Refusal codes
persona-invalid, persona-mismatch, web-source-not-allowed, organization-creative-human-only and
organization-creative-instance are new. decision-not-allowed is named in the brief but is also new in
code. My morgana-0021 message called it an existing code, which was wrong.
Deviations (sage-1492)
-
D1
packages/business/src/business.mjs:checkPersonaVars, plus one call line at the end ofcheckRoles's per-instance block. The persona vars are accepted only on an instance of a creative definition, and only both together. Without D1, any instance's agent layer could carry a persona digest, and the bundle would read a persona file for a pm or chief. Test:business.test.mjs"persona vars: agent layer, creative definition only, both or neither". It covers the business layer, pm and chief, one key alone and malformed values.instance-settings.test.mjsalso covers it: changing a persona-carrying companion to security-reviewer is refused. Mutants: D1a, D1b, D1c. -
D2
packages/cli/src/launcher.mjs: one line,personaDir: join(configDir(env), "personas"),in thebuildBundlecall. Without it a creative instance with a persona can never launch. Test:launcher.test.mjs"row 213 a creative instance's persona is read from /personas: off its digest the launch fails with the mismatch as its reason". Mutant: D2. -
Adapter tests' file (webby-0040 N1). Brief line 68 puts the adapter tests in
claude-session.test.mjsandpi-session.test.mjs. They are inharness/tests/creative.test.mjsinstead, next to the other row 213 harness tests, so the row's adapter cases sit in one file and the two session test files stay unchanged. Same cases: argv holds the absolute prompt path and no marker, and pi refuses a missing, unreadable or directory prompt.
The checkRoles change sits in its own block and the launcher change is one line, so the rebase over rows
194 and 199 (both touch business.mjs, and 199 touches launcher.mjs) stays small.
Pinned-test edits and the pi tightening (sage-1492, named here)
packages/bus/tests/project-runtime.test.mjs:26: the pinned broker import line gainsCREATIVE_DEFINITIONS(M6 reads it).packages/harness/tests/skills.test.mjs:170: the test checked the prompt text in argv after--system-prompt. Each adapter now passes a path, so the test looks up the flag for each harness, checks that the next argument is the bundle's prompt file, and reads the MARKED and chief checks from that file.adapters/pi/adapter.sh:[ -f "$PROMPT_FILE" ] && [ -r "$PROMPT_FILE" ], exit 2 otherwise. Pi 0.85.1 uses the path string as the prompt when it can't read the file. Test:harness/tests/creative.test.mjs"pi refuses a missing, unreadable or directory prompt" (pi never runs). Mutant: A1.
One more pinned assertion changed: business/tests/assistant.test.mjs REQ-HARN-2 expected only the
assistant to hold web. Companion and research now hold it too.
Files
Source: packages/business/src/{vocabulary,role,vars,business,launch-settings,instance-settings}.mjs,
packages/harness/src/{bundle,tools}.mjs, packages/bus/src/broker.mjs, packages/cli/src/launcher.mjs,
adapters/{pi,claude}/adapter.sh, adapters/README.md, roles/{companion,research,ideas,proofreader, fact-checker,continuity}.{json,md}.
Tests changed: business/tests/{role,business,launch-settings,instance-settings,assistant}.test.mjs,
harness/tests/skills.test.mjs, bus/tests/project-runtime.test.mjs, cli/tests/launcher.test.mjs.
New: harness/tests/creative.test.mjs (7), bus/tests/creative.test.mjs (3).
I grepped packages/*/tests and scripts/ for each changed source path next to a 64-hex string. The hits
are thread-migration.test.mjs (a DDL digest, not a pin on broker.mjs; 3/3 pass) and discord's own
tools.mjs. No test pins a changed file.
Evidence
Each file alone from its package with node --test --test-reporter=tap <file>, 2026-10-20, all fail 0:
- Touched: business role 17, business 17, launch-settings 16, instance-settings 12, assistant 11; harness creative 7, skills 10, bundle 22; bus creative 3, project-runtime 3; cli launcher 30. Round 2 reran bus creative, instance-settings, business, launch-settings, role, harness creative and bundle, bus tester-writes 8 and chief-of-staff 18, and the vars readers business vars 7, discord context 7 and git 12.
- Other files that read a changed path: business hard-waits, record-grants, token-limit-settings, workflow-roles, chief-of-staff, project-repo, plugin-settings; harness bundle 22, tools 22, claude-session 8, pi-session 8, raise-domain, spec-tools, claude-gate, mcp-server, gate, runner 34, git-tools; bus hard-waits, launch-notice, tester-writes, business, broker 16, workflow-roles, web 19, task-page, thread-migration 3; discord git, web, setspark, engine, tools; plugins harness, loader; seat working, sandbox, session; cli launch-limit-checks, limits, git-socket, launch-account, thread-launch, launch-limits, launch-routes, instances, stack-copy, stack-release 174; webui limits-browser, editor, settings-form, tester-browser.
Suites, scripts/test-*.sh and the mutants are not run yet. They need Sage's slot.
Drift: git diff --stat 088087ac9 305857c8f over the 35 manifest paths is empty (2026-10-20, sage-1501).
Row 192's async scripts/mosaic-config.mjs and webui-service.sh are outside the manifest. The broker's
new import comes from vocabulary.mjs, which it already imported, so RUNNER_FILES doesn't change.
Mutants: mutants.py in this directory, 31 mutants over 11 files. It backs up each file to scratch first,
aborts on a bad backup or any anchor count other than one (all 31 checked as unique without editing), runs
the named test files per mutant, restores each file after its mutant and again in finally. Log:
mutants.log, written on the run. The brief's models mutant is L1.
| Mutant | File | Tests |
|---|---|---|
| D1a/b/c persona vars check (D1) | business.mjs | business, instance-settings |
| D2 no personaDir (D2) | launcher.mjs | launcher |
L1 models key by anyone (the models mutant) |
launch-settings.mjs | launch-settings |
| L2 fallback.model not checked | launch-settings.mjs | launch-settings |
| L3 instances key allowed | launch-settings.mjs | launch-settings |
| L4 setRoutes by anyone | launch-settings.mjs | launch-settings |
| L5 creativeHumanOnly never refuses | launch-settings.mjs | launch-settings, instance-settings |
| I1/I2/I3 add, change, redefine by anyone | instance-settings.mjs | instance-settings |
| R1/R2/R3 checkCreative off, wider tools, non-human messaging | role.mjs | role |
| B1-B8 mode bits, digest, headings, symlink, char cap, block, no dir, builtin web | bundle.mjs | harness creative |
| T1 raise_decision offered | tools.mjs | harness creative |
| K1 broker raise check off | broker.mjs | bus creative |
| K2/K3 chief or tester session reaches the writer as the human (round 2, B1) | broker.mjs | bus creative |
| V1 persona.name line separators accepted (round 2, N3) | vars.mjs | business |
| A1 pi without -f; A2/A3 relative path kept | adapters | harness creative, skills |
Residuals
ID_PATTERNallows dots, so a human id could read like<definition>.<instance>. That is only cosmetic:creativeHumanOnlycompares the actor withdocument.human.- TOCTOU between pi's
[ -f ] && [ -r ]and pi's own read of the bundle's prompt file. - The other-uid owner check in
readPersonacan't be tested without root. - O_NOFOLLOW covers only the last path component, so a symlinked
personas/orpersonas/<business>/is followed (webby-0040 N2). The digest still refuses any bytes but the pinned ones. A comment inbundle.mjssays so. - The claude adapter checks the prompt file with
[ -r ]only, and pi checks-fand-r. The brief asks for pi's check (webby-0040 N4). - Launch-failed mapping. A BundleError thrown from
buildBundleinsidestart()becomes the Refusallaunch-failed, with the code's message in the log'swhy. That holds for the persona codes and for webPlan'sweb-source-not-allowed. By then the run dirlaunches/<business>/<run>/exists with onlysession/, and no bundle is written. Onlycheck()'s readSkills preflight surfaces bundle codes. The D2 test pins both facts. I kept the launcher change to one line, as sage-1492 asked.
Follow-ups (not in this row)
- A persona preflight in launcher
check(), so a bad persona refuses with its own code before a run dir exists. Sage agreed it as a follow-up (sage-1501) and files it after this row lands. - The webui
bus.mjscode-to-HTTP map lacks the new codes. - webby-0053 N1:
checkCreativedoesn't refusecredentials, so a later role-file commit could give a creative instance a gitea credential. All six creative role files havecredentials: []today. A one-line refusal and arole.test.mjscase close it. - The cli
instances.mjsmessage wording for the creative refusals.
Round 2 (webby-0040, round 1 CHANGES on bb5f4600)
-
B1:
bus/tests/creative.test.mjsadds "M10 through the broker". It runs on a real organization file and writer behindstartBroker, with a claimed chief session, a bound tester session and the human.- The chief's
organization.setof bean'slaunch.modelskey is refused withorganization-creative-human-only. Itsinstanceskey is refused withorganization-creative-instance. - The tester's
organization.setandlimits.setare refusedtester-refused(N5: the broker refuses first). Itsinstances.set(change bean, add an ideas instance) androutes.setfor bean reach the writer astesterand are refused withorganization-creative-human-only. - The file bytes are unchanged and no committed log entry exists after all of these.
- The human's
limits.setof the same change lands, and the one committed entry namesjason. - Mutants K2 and K3 change the broker's actor naming for a role or tester session.
- The chief's
-
B2:
instance-settings.test.mjsasserts the mode ofwriter.logFile("acme")(0600) and that it sits in the history dir. It lives at.history/acme/changes.jsonl, so the round 1 loop over that dir already covered it, but no line named it. -
N1: listed under Deviations. N2: comment and residual. N3:
persona.namenow refuses U+2028 and U+2029, with two values added to the business test and mutant V1. N4: noted under residuals. -
Drift:
git diff --stat 088087ac9 f41729178over the 35 manifest paths is empty. Round 2 manifest digest b5ff0ac0e754cb1e5891417f4ab1da9597f5cf49fb2fe2d5f0fbf066f821c43a.
Round 3 (sage-1715): rebase onto 9b5362018
Rows 199 (cb5fdb064), 237 (d7d943199) and 210 (f5753c0e8) landed on six manifest paths after the round 2
approval. Round 3 rebases the three commits onto origin/refactor 9b5362018 on a new branch,
morgana/row213-r3. The round 2 branch stays as it was. Nothing else changed.
- Five files conflicted. Each resolution keeps both sides:
bus/src/broker.mjsandbus/tests/project-runtime.test.mjs: the vocabulary import keeps rows 199 and 210's names (forkProblem, forkUrl, credentialKind, validArgv, commandAllowed) and adds CREATIVE_DEFINITIONS last. The pinned import line in the test carries the same list. RUNNER_FILES is unchanged, since the import is still the vocabulary file only.business/src/business.mjs: one vocabulary import with row 199's names plus CREATIVE_DEFINITIONS. ThecheckRoles(roles, file, rolesDir, business, hosts)signature is row 199's.checkPersonaVarsruns after row 199's identity and publish checks.business/src/role.mjs: the import keeps row 210's HOST_SERVICE and adds CREATIVE_DEFINITIONS and CREATIVE_TOOLS.business/src/vocabulary.mjs: the creative constants andEND_PROTECTED = THREAD_DEFINITIONScome before row 199's Gitea URL parse, which is kept whole.
cli/src/launcher.mjsmerged without a conflict.git range-diff 088087ac9..aee32d185 9b5362018..morgana/row213-r3: commits 2 and 3 are=. Commit 1 differs only in the conflict hunks above.- None of the new base's added lines name END_PROTECTED, THREAD_DEFINITION, CREATIVE_* or the creative refusal codes, apart from docs. All 31 mutant anchors still count 1. The hash-pin grep finds no 64-hex pin on a manifest path.
- The manifest is rebuilt from the round 3 tree. It has the same 35 paths, and six digests changed: the
six drifted files.
base.txtis9b5362018. - Singles of every touched test file, plus the seat sandbox and bus project-runtime tests (RUNNER_FILES rule), wait on Sage's slot. They are recorded here when run.
Round 4 (sage-1778, mos-1160): one commit on row 234's landing c771a36f4
Row 234 landed as c771a36f4 after the round 3 approval (webby-0053). Round 4 is one commit on c771a36f4,
on the new branch morgana/row213-r4. It is the round 3 change applied onto the landed blobs. The round 3
branch stays as it was.
- Overlap,
git diff --name-only 9b5362018 c771a36f4against the round 3 paths:business/src/business.mjs,bus/src/broker.mjsandbus/tests/project-runtime.test.mjs. Each starts from the landed blob, and the row 213 edits are merged by hand. No landed hunk is reverted (sage-1760).broker.mjsand the pinnedproject-runtimeimport line: row 234's list withHOST_ID, thenCREATIVE_DEFINITIONS. The test keeps both rows' comments. The import still names onlyvocabulary.mjs, so RUNNER_FILES is unchanged.business.mjs: row 234's import (noHOST_PREFIX, nohosts.mjs) plusCREATIVE_DEFINITIONS.checkPersonaVarssits before row 234'sHOSTS_MOVEDand its four-argumentcheckRoles, and still runs after the identity and publish checks.
- Check: the removed and added non-packet lines of
diff c771a36f4..r4anddiff 9b5362018..2d2ad2760differ only in those three import lines. All 31 mutant anchors count 1. - Row 234's hosts verbs:
#hostsCalleradmits the human, the tester or the chief definition (grant-chief-only), so a creative role can't reach them and M1 stays as narrow. - The manifest has the same 35 paths. The three overlap files changed digest.
base.txtisc771a36f4. - Singles and the 31 mutants wait on Sage's slot. They are recorded here when run.