@arjunkhera/atlas 0.4.37 → 0.4.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -266,7 +266,7 @@ export function correctionsOf(marks = []) {
266
266
  }
267
267
 
268
268
  export function createDeliveryVerbs(core) {
269
- const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, ownerPresent, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
269
+ const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, patch, create, edge, landed, alreadyPatched, readGitHub, readLive } = core;
270
270
 
271
271
  const github = async (path) => {
272
272
  if (typeof readGitHub !== 'function') refuse('this verb needs a GitHub reader; the server supplies one');
@@ -324,9 +324,7 @@ export function createDeliveryVerbs(core) {
324
324
  }
325
325
  return null;
326
326
  }
327
- // The reopen door (R9). The owner-present flag, or the lead in this session.
328
- const requireOwnerOrLead = (verb) => { if (!ownerPresent && label.agent !== 'lead') refuse(`${verb} is for the owner or the lead in session; this session is agent "${label.agent}"`); };
329
-
327
+ // The reopen door (R9) is a row of the role table (roles.mjs, R20): the lead.
330
328
  const markOf = async (id, itemId) => {
331
329
  const type = await typeNamed('mark');
332
330
  let entity;
@@ -432,9 +430,11 @@ export function createDeliveryVerbs(core) {
432
430
  //
433
431
  // A decision record is an Engram entity, and any Engram writer can make one
434
432
  // that looks right. So the acceptance is not taken from the record alone:
435
- // passing accepted_red needs the owner-present flag, the same flag that
436
- // decision_record needs. A forged record then gives a caller nothing it
437
- // could not write itself (security review of this change, finding 1).
433
+ // passing accepted_red needs the lead role, the same role that
434
+ // decision_record needs (the role table, roles.mjs, R20). A forged record
435
+ // then gives a caller nothing it could not write itself (security review
436
+ // of this change, finding 1). The lead role is not the owner's own act; the
437
+ // signed waiver below is.
438
438
  // R17 line 6: a decision record alone does not waive a red check. The owner
439
439
  // signs a waiver act with his security key (R72, design
440
440
  // docs/design-docs/signed-act/) and posts the block on the pull request.
@@ -530,10 +530,9 @@ export function createDeliveryVerbs(core) {
530
530
 
531
531
  const verbs = {
532
532
  // The delivering agent marks its own change done, after the verb runs the
533
- // three live checks itself. No owner flag: the agent that shipped it calls
534
- // this, and the lead catches up a delivery made outside a session (R2).
533
+ // three live checks itself. The role table gives it to the lead, and
534
+ // accepted_red, the owner's words, to the lead too (roles.mjs, R20).
535
535
  async item_done(args, requestId, meta) {
536
- if (args.accepted_red !== undefined && !ownerPresent) refuse('accepted_red needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1): only the owner waives a red check');
537
536
  const item = await requireItem(args.item_id);
538
537
  const fields = item.fields ?? {};
539
538
  const replay = alreadyPatched(item, requestId, meta);
@@ -750,11 +749,10 @@ export function createDeliveryVerbs(core) {
750
749
  };
751
750
  },
752
751
 
753
- // Reopen: the owner or the lead in session, with a reason and the failed
752
+ // Reopen: the lead in session, with a reason and the failed
754
753
  // check as a URL. It appends a mark that corrects the delivery. The
755
754
  // delivery mark is never touched (R9).
756
755
  async item_reopen(args, requestId, meta) {
757
- requireOwnerOrLead('item reopen');
758
756
  const item = await requireItem(args.item_id);
759
757
  const fields = item.fields ?? {};
760
758
  const reason = text(args.reason, 'reason', { max: 4000 });
@@ -119,7 +119,7 @@ export function itemLine(item, product, lifecycle = 'active') {
119
119
  }
120
120
 
121
121
  export function createQuestionVerbs(core) {
122
- const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated } = core;
122
+ const { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, patch, create, edge, landed, alreadyPatched, findCreated } = core;
123
123
 
124
124
  async function recordOfKind(id, kind, name) {
125
125
  text(id, name, { max: 200 });
@@ -193,7 +193,6 @@ export function createQuestionVerbs(core) {
193
193
 
194
194
  // The owner answers. One call writes the decision, the receipt, and closes the question.
195
195
  async question_answer(args, requestId, meta) {
196
- requireOwner('question answer');
197
196
  const question = await recordOfKind(args.question_id, 'question', 'question_id');
198
197
  const answer = text(args.text, 'text', { max: MAX_RENDERED });
199
198
  const date = isoDate(args.date, 'date');
@@ -229,7 +228,6 @@ export function createQuestionVerbs(core) {
229
228
 
230
229
  // The owner speaks outside a question. D2: refuses with no verbatim text, no date.
231
230
  async decision_record(args, requestId, meta) {
232
- requireOwner('decision record');
233
231
  const decisionText = text(args.text, 'text', { max: MAX_RENDERED });
234
232
  const date = isoDate(args.date, 'date');
235
233
  const source = text(args.source, 'source', { optional: true, max: 500 }) ?? '';
@@ -0,0 +1,135 @@
1
+ // Roadmap R20: the roles of the tools server, and the one table that says
2
+ // who may call each verb. Every guard reads only this table.
3
+ //
4
+ // The probe of 26 October 2026 found no harness marker that tells a lead, a
5
+ // sub-agent and a scheduled run apart: a sub-agent shares the lead's server
6
+ // process, and a scheduled run carries the same variables as a project
7
+ // thread. So the role comes from two things only:
8
+ // 1. the key type, which gives the widest role: a user key gives lead, an
9
+ // agent key gives routine. Today the server starts with a user key
10
+ // only, so routine comes from the marker until R22 opens the agent key;
11
+ // 2. the launcher's marker ATLAS_WORK_ROLE, which can only narrow it.
12
+ // No role is the owner. The lead writes down the owner's words; the owner's
13
+ // own act is a signed act (R72), which the owner door reads (R27).
14
+ import { refuse } from './verb-fields.mjs';
15
+
16
+ // Widest first. A role may call what a later role may call, and more.
17
+ export const ROLES = Object.freeze(['lead', 'routine', 'crew']);
18
+ const ALL = ROLES;
19
+ const LEAD = Object.freeze(['lead']);
20
+ const LEAD_AND_ROUTINE = Object.freeze(['lead', 'routine']);
21
+ const rank = (role) => ROLES.indexOf(role);
22
+
23
+ export const READ_ROLES = ALL;
24
+
25
+ // Each write verb, and atlas_upgrade, with the roles that may call it.
26
+ // owner_words marks a verb whose record is the owner's words, written down
27
+ // by the lead. The stamp still says lead: it is never the owner's own act.
28
+ export const VERB_ROLES = Object.freeze({
29
+ setup: { roles: LEAD },
30
+ product_add: { roles: LEAD },
31
+ product_edit: { roles: LEAD },
32
+ repo_add: { roles: LEAD },
33
+ repo_edit: { roles: LEAD },
34
+ item_propose: { roles: LEAD_AND_ROUTINE },
35
+ item_lock: { roles: LEAD, owner_words: true },
36
+ item_split: { roles: LEAD },
37
+ item_park: { roles: LEAD, owner_words: true },
38
+ item_resume: { roles: LEAD, owner_words: true },
39
+ item_drop: { roles: LEAD, owner_words: true },
40
+ item_edit: { roles: LEAD },
41
+ item_block: { roles: LEAD },
42
+ item_unblock: { roles: LEAD },
43
+ item_stage: { roles: LEAD },
44
+ item_link: { roles: LEAD },
45
+ item_comment: { roles: LEAD_AND_ROUTINE },
46
+ step_in_name: { roles: LEAD },
47
+ lesson_record: { roles: LEAD },
48
+ question_ask: { roles: LEAD },
49
+ question_answer: { roles: LEAD, owner_words: true },
50
+ decision_record: { roles: LEAD, owner_words: true },
51
+ item_done: { roles: LEAD },
52
+ item_reopen: { roles: LEAD },
53
+ atlas_upgrade: { roles: LEAD },
54
+ });
55
+
56
+ // The owner-only arguments: a verb that one role may call, with an argument
57
+ // that only a narrower set of roles may pass. when says if a call holds it.
58
+ export const ARGUMENT_ROLES = Object.freeze({
59
+ 'item_edit.title': { verb: 'item_edit', roles: LEAD, owner_words: true, when: (args) => args.title !== undefined },
60
+ 'item_edit.plan': { verb: 'item_edit', roles: LEAD, owner_words: true, when: (args) => args.plan !== undefined },
61
+ 'item_comment.voice': { verb: 'item_comment', roles: LEAD, owner_words: true, name: '`voice: owner`', when: (args) => args.voice === 'owner' },
62
+ 'item_done.accepted_red': { verb: 'item_done', roles: LEAD, owner_words: true, when: (args) => args.accepted_red !== undefined },
63
+ 'atlas_upgrade.check': { verb: 'atlas_upgrade', roles: ALL, widens: true, name: '`check: true`', when: (args) => args.check === true },
64
+ });
65
+
66
+ // A widening row replaces the roles of its verb, so it would undo a
67
+ // narrowing row on the same verb. The table refuses to load with both.
68
+ for (const [key, argument] of Object.entries(ARGUMENT_ROLES)) {
69
+ if (!argument.widens) continue;
70
+ const other = Object.keys(ARGUMENT_ROLES).find((name) => name !== key && ARGUMENT_ROLES[name].verb === argument.verb);
71
+ if (other) throw new Error(`role table: ${key} widens ${argument.verb}, so ${other} may not narrow it`);
72
+ }
73
+
74
+ const KEY_ROLES = Object.freeze({ user: 'lead', agent: 'routine' });
75
+
76
+ // The role of a server. keyType is the principal_type of the Engram key;
77
+ // marker is ATLAS_WORK_ROLE. A marker the table does not know narrows to the
78
+ // least role, so a typo never widens. The answer says why, for the
79
+ // instructions and the health file.
80
+ export function roleOf({ keyType, marker } = {}) {
81
+ const fromKey = KEY_ROLES[keyType] ?? 'crew';
82
+ const key = keyType === 'agent' ? 'an agent key' : keyType === 'user' ? 'a user key' : 'no known key';
83
+ const said = typeof marker === 'string' ? marker.trim() : '';
84
+ if (!said) return { role: fromKey, from: key };
85
+ const fromMarker = ROLES.includes(said) ? said : 'crew';
86
+ const role = rank(fromMarker) > rank(fromKey) ? fromMarker : fromKey;
87
+ const why = ROLES.includes(said) ? `ATLAS_WORK_ROLE=${said}` : `ATLAS_WORK_ROLE=${said}, which is not a role`;
88
+ return { role, from: `${key} with ${why}` };
89
+ }
90
+
91
+ // The roles that may make this call. A read verb is open to every role.
92
+ export function rolesFor(verb, args = {}, { readVerbs = [] } = {}) {
93
+ if (readVerbs.includes(verb)) return READ_ROLES;
94
+ const row = VERB_ROLES[verb];
95
+ if (!row) return null;
96
+ let roles = row.roles;
97
+ for (const argument of Object.values(ARGUMENT_ROLES)) {
98
+ if (argument.verb !== verb || !argument.when(args)) continue;
99
+ roles = argument.widens ? argument.roles : roles.filter((role) => argument.roles.includes(role));
100
+ }
101
+ return roles;
102
+ }
103
+
104
+ // The name of an argument row, as the docs and the refusals print it.
105
+ const nameOf = (argument) => argument.name ?? `\`${Object.keys(ARGUMENT_ROLES).find((key) => ARGUMENT_ROLES[key] === argument).split('.')[1]}\``;
106
+
107
+ // The one guard. It refuses a call that the table does not give this role.
108
+ export function allow(role, verb, args = {}, { readVerbs = [], from } = {}) {
109
+ const roles = rolesFor(verb, args, { readVerbs });
110
+ if (!roles) refuse(`${verb} has no row in the role table (packages/atlas/work/lib/roles.mjs); it is refused for every role`);
111
+ if (roles.includes(role)) return;
112
+ const named = Object.values(ARGUMENT_ROLES).find((argument) => argument.verb === verb && argument.when(args) && !argument.widens && !argument.roles.includes(role));
113
+ const what = named ? `${verb} with ${nameOf(named).replaceAll('`', '')}` : verb;
114
+ const who = (named?.roles ?? roles).join(' or ');
115
+ refuse(`${what} is for the ${who}. This server runs as ${role}${from ? `, from ${from}` : ''}`);
116
+ }
117
+
118
+ // The Who cell of work/VERBS.md for one verb, generated from the table
119
+ // (R20 line 7). node work/who.mjs --write puts it in the file.
120
+ export function whoCell(verb, { readVerbs = [] } = {}) {
121
+ if (readVerbs.includes(verb) && !VERB_ROLES[verb]) return 'every role';
122
+ const row = VERB_ROLES[verb];
123
+ if (!row) return null;
124
+ const parts = [row.roles.join(', ')];
125
+ const words = [];
126
+ for (const argument of Object.values(ARGUMENT_ROLES)) {
127
+ if (argument.verb !== verb) continue;
128
+ const same = argument.roles.length === row.roles.length && argument.roles.every((role) => row.roles.includes(role));
129
+ if (!same) parts.push(`${argument.roles.length === ROLES.length ? 'every role' : argument.roles.join(', ')} for ${nameOf(argument)}`);
130
+ if (argument.owner_words) words.push(nameOf(argument));
131
+ }
132
+ if (row.owner_words) parts.push("the owner's words");
133
+ if (words.length) parts.push(`${words.join(' and ')} ${words.length === 1 ? 'is' : 'are'} the owner's words`);
134
+ return parts.join('; ');
135
+ }
@@ -22,6 +22,7 @@ import { createCapabilityVerbs, CAPABILITY_READ_VERBS, hubOf } from './capabilit
22
22
  import { createRoundupVerbs, ROUNDUP_READ_VERBS } from './roundup.mjs';
23
23
  import { createActivityVerbs, diffOf, LOG_VERBS, ENTRY_VERBS, ACTIVITY_WRITE_VERBS, ACTIVITY_READ_VERBS } from './activity.mjs';
24
24
  import { createStepInVerbs, STEP_IN_WRITE_VERBS, STEP_IN_READ_VERBS } from './step-ins.mjs';
25
+ import { ROLES, allow } from './roles.mjs';
25
26
  import { createLessonVerbs, LESSON_WRITE_VERBS, LESSON_READ_VERBS } from './lessons.mjs';
26
27
 
27
28
  export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
@@ -106,12 +107,18 @@ const renderHandle = (value, handle) => JSON.parse(JSON.stringify(value).replace
106
107
  // Sortable form of a three-part version, for "is the tenant ahead of us?".
107
108
  const semver = (value) => String(value).split('.').map((part) => String(Number(part) || 0).padStart(6, '0')).join('.');
108
109
 
109
- export function createVerbs({ client, store, writer, session, agent = 'lead', boundClient = null, ownerPresent = false, readGitHub, readLive, now = () => new Date().toISOString() } = {}) {
110
+ // The verbs that write nothing. The role table opens them to every role (R20).
111
+ export const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS, ...LESSON_READ_VERBS]);
112
+
113
+ export function createVerbs({ client, store, writer, session, agent, role, roleFrom, boundClient = null, readGitHub, readLive, now = () => new Date().toISOString() } = {}) {
110
114
  if (!client) throw new TypeError('client is required');
111
115
  if (!store) throw new TypeError('store (the request journal) is required');
112
116
  if (typeof writer?.token_id !== 'string' || !writer.token_id) throw new TypeError('writer.token_id is required');
113
117
  if (typeof session !== 'string' || !session.trim()) throw new TypeError('session is required; the server supplies it');
114
- const label = Object.freeze({ token_id: writer.token_id, session, agent, ...(boundClient ? { client: String(boundClient) } : {}) });
118
+ // Roadmap R20: the role decides what this server may call, and the
119
+ // writer stamp names it. The agent label is a name only; it gives no right.
120
+ if (!ROLES.includes(role)) throw new TypeError(`role must be one of ${ROLES.join(', ')}`);
121
+ const label = Object.freeze({ token_id: writer.token_id, session, agent: agent ?? role, role, ...(boundClient ? { client: String(boundClient) } : {}) });
115
122
  let handleCache = null, typesCache = null;
116
123
 
117
124
  async function handle() {
@@ -191,7 +198,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
191
198
  if (entity.state && entity.state !== 'active') refuse(`item ${id} is ${entity.state}, not active in the graph`);
192
199
  return entity;
193
200
  }
194
- const requireOwner = (verb) => { if (!ownerPresent) refuse(`${verb} needs the owner-present flag on the server host (ATLAS_WORK_OWNER_PRESENT=1)`); };
195
201
 
196
202
  // fields may be a function of the record as read, so a retry after a 409
197
203
  // recomputes it from the newer record. guard, when given, is checked again on
@@ -264,7 +270,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
264
270
 
265
271
  const verbs = {
266
272
  async setup(args) {
267
- requireOwner('setup');
268
273
  const publisherHandle = await handle();
269
274
  const rendered = renderHandle(manifestCurrent, publisherHandle);
270
275
  if (rendered.name !== 'atlas-work' || rendered.version !== MANIFEST_VERSION) throw new Error(`local manifest must be atlas-work ${MANIFEST_VERSION}`);
@@ -321,7 +326,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
321
326
  },
322
327
 
323
328
  async product_add(args, requestId, meta) {
324
- requireOwner('product add');
325
329
  const registryId = text(args.registry_id, 'registry_id', { max: 64 });
326
330
  if (!PRODUCT_ID.test(registryId)) refuse('registry_id must be a lowercase slug: letters, digits and hyphens, for example "engram"');
327
331
  const name = text(args.name, 'name', { max: 200 });
@@ -347,7 +351,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
347
351
  },
348
352
 
349
353
  async product_edit(args, requestId, meta) {
350
- requireOwner('product edit');
351
354
  const registryId = text(args.registry_id, 'registry_id', { max: 64 });
352
355
  const reg = await registry();
353
356
  const record = await requireProduct(registryId, reg);
@@ -376,7 +379,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
376
379
  },
377
380
 
378
381
  async repo_add(args, requestId, meta) {
379
- requireOwner('repo add');
380
382
  if (typeof readGitHub !== 'function') refuse('repo add needs a GitHub reader for the push-rights check');
381
383
  const registryId = text(args.registry_id, 'registry_id', { max: 200 });
382
384
  if (!REPO_ID.test(registryId)) refuse('registry_id must be a GitHub "owner/repo" name');
@@ -401,7 +403,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
401
403
  },
402
404
 
403
405
  async repo_edit(args, requestId, meta) {
404
- requireOwner('repo edit');
405
406
  const registryId = text(args.registry_id, 'registry_id', { max: 200 });
406
407
  const reg = await registry();
407
408
  const record = reg.repos.get(registryId);
@@ -425,7 +426,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
425
426
  },
426
427
 
427
428
  async item_propose(args, requestId, meta) {
428
- requireOwner('item propose');
429
429
  const title = text(args.title, 'title', { max: 300 });
430
430
  const reg = await registry();
431
431
  const product = await requireProduct(args.product, reg);
@@ -443,7 +443,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
443
443
  },
444
444
 
445
445
  async item_lock(args, requestId, meta) {
446
- requireOwner('item lock');
447
446
  const item = await requireItem(args.item_id);
448
447
  const fields = item.fields ?? {};
449
448
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_lock', item_id: item.id, version: item.version, lifecycle: fields.lifecycle, stage: fields.stage, product: fields.product, repos: fields.repos, scope_hash: fields.scope_hash, locked_at: fields.locked_at, replayed_from_graph: true };
@@ -473,7 +472,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
473
472
  },
474
473
 
475
474
  async item_split(args, requestId, meta) {
476
- requireOwner('item split');
477
475
  const parent = await requireItem(args.item_id);
478
476
  const reason = text(args.reason, 'reason', { max: 2000 });
479
477
  // split_order is a piece's place in the parent's split_children. That
@@ -534,7 +532,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
534
532
  },
535
533
 
536
534
  async item_park(args, requestId, meta) {
537
- requireOwner('item park');
538
535
  const item = await requireItem(args.item_id);
539
536
  const reason = text(args.reason, 'reason', { max: 2000 });
540
537
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_park', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
@@ -544,7 +541,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
544
541
  },
545
542
 
546
543
  async item_resume(args, requestId, meta) {
547
- requireOwner('item resume');
548
544
  const item = await requireItem(args.item_id);
549
545
  const reason = text(args.reason, 'reason', { max: 2000 });
550
546
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_resume', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, reason, replayed_from_graph: true };
@@ -565,7 +561,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
565
561
  // dropped item stays in the graph; no live view lists it. A delivered item
566
562
  // is not dropped: item_reopen is its path.
567
563
  async item_drop(args, requestId, meta) {
568
- requireOwner('item drop');
569
564
  const item = await requireItem(args.item_id);
570
565
  const words = origin(args.origin);
571
566
  if (alreadyPatched(item, requestId, meta)) return { verb: 'item_drop', item_id: item.id, version: item.version, lifecycle: item.fields.lifecycle, dropped_from: item.fields.dropped_from ?? null, replayed_from_graph: true };
@@ -578,8 +573,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
578
573
 
579
574
  // Release 0.2.3: edit an item's next step, what it waits on, and its title.
580
575
  // The next step and the wait are the lead's to keep true, so any
581
- // in-session caller may change them. The title is the owner's words, so a
582
- // new title needs the owner present. Each edit keeps the old values.
576
+ // in-session caller may change them. The title is the owner's words, so only
577
+ // the lead role sets a new title. Each edit keeps the old values.
583
578
  // Work item additions, slice 1: tags_add and tags_remove change the free
584
579
  // tags, and plan holds the owner's planned start, end and deadline.
585
580
  async item_edit(args, requestId, meta) {
@@ -591,7 +586,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
591
586
  const change = {}, previous = {};
592
587
  let title;
593
588
  if (args.title !== undefined) {
594
- requireOwner('item edit of a title');
595
589
  title = text(args.title, 'title', { max: 300 });
596
590
  if (title !== item.title) previous.title = item.title ?? '';
597
591
  }
@@ -618,7 +612,6 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
618
612
  let tagEdit = tagsAdd.length || tagsRemove.length;
619
613
  let planGiven = null;
620
614
  if (args.plan !== undefined) {
621
- requireOwner('item edit of planned dates');
622
615
  if (!args.plan || typeof args.plan !== 'object' || Array.isArray(args.plan)) refuse(`plan must be { ${PLAN_DATES.join(', ')} }, each a date YYYY-MM-DD or null to clear it`);
623
616
  const unknown = Object.keys(args.plan).filter((key) => !PLAN_DATES.includes(key));
624
617
  if (unknown.length) refuse(`plan holds only ${PLAN_DATES.join(', ')}; not ${unknown.join(', ')}`);
@@ -653,8 +646,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
653
646
 
654
647
  // Work item additions, slice 2: an item names the items that block it.
655
648
  // The list on the item is the one home of the fact; a view works out
656
- // "blocks" from the items it loaded. The lead keeps the links true, so no
657
- // owner flag is needed. A link that would close a loop is refused.
649
+ // "blocks" from the items it loaded. The lead keeps the links true (the role
650
+ // table gives it to the lead). A link that would close a loop is refused.
658
651
  async item_block(args, requestId, meta) {
659
652
  const item = await requireItem(args.item_id);
660
653
  text(args.blocked_by, 'blocked_by', { max: 200 });
@@ -744,9 +737,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
744
737
  // registry and this writer stamp. The delivery verbs also get the two
745
738
  // read-only readers: GitHub through the gh CLI, and one GET against the
746
739
  // deployed app. Slice L4 adds three read verbs, which write nothing.
747
- const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, requireOwner, patch, create, edge, landed, alreadyPatched, findCreated };
740
+ const core = { client, label, now, typeNamed, kindOf, registry, requireProduct, requireItem, patch, create, edge, landed, alreadyPatched, findCreated };
748
741
  Object.assign(verbs, createQuestionVerbs(core));
749
- Object.assign(verbs, createDeliveryVerbs({ ...core, ownerPresent, readGitHub, readLive }));
742
+ Object.assign(verbs, createDeliveryVerbs({ ...core, readGitHub, readLive }));
750
743
  Object.assign(verbs, createCapabilityVerbs({ ...core, readGitHub }));
751
744
  Object.assign(verbs, createRoundupVerbs({ ...core, readGitHub, inFlight: (args) => verbs.in_flight(args), needsMe: (args) => verbs.needs_me(args) }));
752
745
  const activity = createActivityVerbs(core);
@@ -759,12 +752,12 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
759
752
  Object.assign(verbs, createLessonVerbs(core));
760
753
 
761
754
  const WRITE_VERBS = Object.freeze(['setup', 'product_add', 'product_edit', 'repo_add', 'repo_edit', 'item_propose', 'item_lock', 'item_split', 'item_park', 'item_resume', 'item_drop', 'item_edit', 'item_block', 'item_unblock', 'item_stage', 'item_link', ...QUESTION_WRITE_VERBS, ...DELIVERY_WRITE_VERBS, ...ACTIVITY_WRITE_VERBS, ...STEP_IN_WRITE_VERBS, ...LESSON_WRITE_VERBS]);
762
- const READ_VERBS = Object.freeze(['registry_list', ...QUESTION_READ_VERBS, ...DELIVERY_READ_VERBS, ...CAPABILITY_READ_VERBS, ...ROUNDUP_READ_VERBS, ...ACTIVITY_READ_VERBS, ...STEP_IN_READ_VERBS, ...LESSON_READ_VERBS]);
763
755
 
764
756
  async function run(verb, args = {}) {
765
757
  if (!verbs[verb]) refuse(`unknown verb: ${verb}`);
766
758
  if (!args || typeof args !== 'object' || Array.isArray(args)) refuse('arguments must be an object');
767
759
  for (const key of AUTHORITY_FIELDS) if (key in args) refuse(`the request cannot supply ${key}; the server stamps the writer and session`);
760
+ allow(role, verb, args, { readVerbs: READ_VERBS, from: roleFrom });
768
761
  if (READ_VERBS.includes(verb)) return verbs[verb](args);
769
762
  const requestId = text(args.request_id, 'request_id', { max: 200 });
770
763
  if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(requestId)) refuse('request_id may hold letters, digits, dots, colons, underscores and hyphens');