hub-proposal-personal-self-apply.mjs
651 lines 24.1 KB
Raw
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 42 days ago
1 /**
2 * Scooling personal self-apply class — HOSTED-WRITE-EVAL §HWE.3 + SEC-SEAM-1 +
3 * FINISH-COMPLETE-APPLY T5 + FLOW-WRITE-LIVE §FWL.4.
4 *
5 * Narrow predicate: hosted (or Node-parity) actors with vault:write may approve
6 * their own partition proposals that match an admitted fingerprint (review tray,
7 * Tasks/Media under §FCA.4, or Flow authoring under §FWL.4.1), without a Hub
8 * evaluation hop. Not a global member-approve grant.
9 *
10 * SEC-SEAM-1: session-bound author==approver for seam surfaces; named refusals;
11 * classification reuses apply-path predicates (S3.0). FINISH-COMPLETE-APPLY-KN-b
12 * (T5) admits Tasks/Media fingerprints; FLOW-WRITE-LIVE-KN-b widens T5 for
13 * Wave 1 Flow authoring (`new`|`edit`|`import`). Delegation stays unconditional
14 * SELF_APPLY_DELEGATION_REFUSED; flow_capture stays SELF_APPLY_NOT_ADMITTED.
15 *
16 * @see docs/PROPOSAL-LIFECYCLE.md — Personal self-apply
17 * @see docs/SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md
18 * @see ../scooling/docs/FINISH-COMPLETE-APPLY-CONTRACT.md §FCA.4
19 * @see ../scooling/docs/FLOW-WRITE-LIVE-FREEZE.md §FWL.4
20 * @see ../scooling/docs/HOSTED-WRITE-EVAL-CONTRACT.md §HWE.3
21 */
22
23 import { normalizeCanisterProposalForTaskPrecheck } from './task/task-hosted-proposal.mjs';
24 import { normalizeCanisterProposalForDelegationPrecheck } from './agent/delegation-hosted-proposal.mjs';
25 import { TASK_PROPOSAL_SOURCE } from './task/task-write.mjs';
26 import { DELEGATION_PROPOSAL_SOURCE } from './agent/delegation.mjs';
27 import { MEDIA_PROPOSAL_SOURCE } from './attachments/attachment-write.mjs';
28 import { FLOW_PROPOSAL_SOURCE } from './flow/flow-authoring.mjs';
29 import { FLOW_CAPTURE_PROPOSAL_SOURCE } from './flow/flow-capture.mjs';
30 import { isSelfApplyIneligibleSub } from './hub-self-apply-ineligible.mjs';
31 import {
32 SCOOLING_TASK_EXTERNAL_REF_RE,
33 SCOOLING_MEDIA_EXTERNAL_REF_RE,
34 SCOOLING_FLOW_EXTERNAL_REF_RE,
35 resolveOptionalScoolingExternalRef,
36 } from './scooling-external-ref.mjs';
37
38 export {
39 SCOOLING_TASK_EXTERNAL_REF_RE,
40 SCOOLING_MEDIA_EXTERNAL_REF_RE,
41 SCOOLING_FLOW_EXTERNAL_REF_RE,
42 resolveOptionalScoolingExternalRef,
43 };
44
45 /** @type {string} */
46 export const SCOOLING_REVIEW_TRAY_INTENT = 'scooling.review_tray.approve';
47
48 /** external_ref: scooling.review:{id} with bounded charset */
49 export const SCOOLING_REVIEW_EXTERNAL_REF_RE = /^scooling\.review:[A-Za-z0-9._:-]{1,200}$/;
50
51 /** path: reviewed/{slug}.md */
52 export const SCOOLING_REVIEWED_PATH_RE = /^reviewed\/[A-Za-z0-9._:-]{1,128}\.md$/;
53
54 /** FINISH-COMPLETE-APPLY §FCA.4.1 — task proposal mirror path */
55 export const SCOOLING_TASK_PROPOSAL_PATH_RE =
56 /^meta\/tasks\/proposals\/([A-Za-z0-9._:-]{1,128})\.json$/;
57
58 /** FINISH-COMPLETE-APPLY §FCA.4.2 — media proposal mirror path */
59 export const SCOOLING_MEDIA_PROPOSAL_PATH_RE =
60 /^meta\/media\/proposals\/([A-Za-z0-9._:-]{1,128})\.json$/;
61
62 /** FLOW-WRITE-LIVE §FWL.4.1 — Flow mirror path (approve-time admission) */
63 export const SCOOLING_FLOW_MIRROR_PATH_RE =
64 /^meta\/flows\/[A-Za-z0-9._:-]{1,128}\.md$/;
65
66 /** Closed Flow kind allowlist (§FWL.4.1) — exact FlowProposeKind; no default. */
67 export const ADMITTED_FLOW_PROPOSAL_KINDS = Object.freeze(['new', 'edit', 'import']);
68
69 const ADMITTED_FLOW_KIND_SET = new Set(ADMITTED_FLOW_PROPOSAL_KINDS);
70
71 /** Closed task kind allowlist (§FCA.4.1). */
72 export const ADMITTED_TASK_PROPOSAL_KINDS = Object.freeze([
73 'task_create',
74 'task_status_update',
75 'task_assign',
76 'task_artifact_link',
77 'task_loop_create',
78 'task_loop_pause',
79 'task_loop_cancel',
80 'task_instance_materialize',
81 ]);
82
83 const ADMITTED_TASK_KIND_SET = new Set(ADMITTED_TASK_PROPOSAL_KINDS);
84
85 /** Closed media kind allowlist (§FCA.4.2). */
86 export const ADMITTED_MEDIA_PROPOSAL_KINDS = Object.freeze([
87 'media_external_link',
88 'media_attach',
89 ]);
90
91 const ADMITTED_MEDIA_KIND_SET = new Set(ADMITTED_MEDIA_PROPOSAL_KINDS);
92
93 /** HTTP-visible seam refusal codes (S6) — never collapsed to generic FORBIDDEN. */
94 export const SELF_APPLY_HTTP_VISIBLE_SEAM_CODES = Object.freeze([
95 'SELF_APPLY_SESSION_BINDING_REQUIRED',
96 'SELF_APPLY_AUTHOR_UNVERIFIED',
97 'SELF_APPLY_AUTHOR_MISMATCH',
98 'SELF_APPLY_DELEGATION_REFUSED',
99 'SELF_APPLY_NOT_ADMITTED',
100 ]);
101
102 const HTTP_VISIBLE_SEAM_CODE_SET = new Set(SELF_APPLY_HTTP_VISIBLE_SEAM_CODES);
103
104 /**
105 * Stable error bodies for HTTP-visible seam codes.
106 * @type {Readonly<Record<string, string>>}
107 */
108 export const SELF_APPLY_SEAM_ERROR_MESSAGES = Object.freeze({
109 SELF_APPLY_SESSION_BINDING_REQUIRED:
110 'Personal self-apply requires a session-bound learner credential on seam proposals.',
111 SELF_APPLY_AUTHOR_UNVERIFIED:
112 'Personal self-apply requires a verified proposal author on seam proposals.',
113 SELF_APPLY_AUTHOR_MISMATCH:
114 'Personal self-apply requires the approver to match the proposal author on seam proposals.',
115 SELF_APPLY_DELEGATION_REFUSED:
116 'Personal self-apply is refused for delegation surface proposals.',
117 SELF_APPLY_NOT_ADMITTED:
118 'Personal self-apply is not admitted for this seam proposal.',
119 });
120
121 /**
122 * Whether a refusal code must be returned verbatim over HTTP (S6 / S6.2).
123 * @param {string|null|undefined} code
124 * @returns {boolean}
125 */
126 export function isHttpVisibleSelfApplySeamCode(code) {
127 return typeof code === 'string' && HTTP_VISIBLE_SEAM_CODE_SET.has(code);
128 }
129
130 /**
131 * Normalize auto-flag reasons from proposal or create body (array or JSON string).
132 * @param {Record<string, unknown>|null|undefined} proposal
133 * @returns {string[]}
134 */
135 export function parseAutoFlagReasons(proposal) {
136 if (!proposal || typeof proposal !== 'object') return [];
137 if (Array.isArray(proposal.auto_flag_reasons)) {
138 return proposal.auto_flag_reasons.map((x) => String(x)).filter(Boolean);
139 }
140 const raw = proposal.auto_flag_reasons_json;
141 if (raw == null || String(raw).trim() === '') return [];
142 if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
143 try {
144 const parsed = JSON.parse(String(raw));
145 return Array.isArray(parsed) ? parsed.map((x) => String(x)).filter(Boolean) : [];
146 } catch {
147 return [];
148 }
149 }
150
151 /**
152 * P3–P5 fingerprint (intent + external_ref + path).
153 * @param {Record<string, unknown>|null|undefined} proposal
154 * @returns {boolean}
155 */
156 export function matchesScoolingReviewTrayFingerprint(proposal) {
157 if (!proposal || typeof proposal !== 'object') return false;
158 const intent = String(proposal.intent ?? '').trim();
159 if (intent !== SCOOLING_REVIEW_TRAY_INTENT) return false;
160 const externalRef = String(proposal.external_ref ?? '').trim();
161 if (!SCOOLING_REVIEW_EXTERNAL_REF_RE.test(externalRef)) return false;
162 const notePath = String(proposal.path ?? '').trim().replace(/^\/+/, '');
163 if (!SCOOLING_REVIEWED_PATH_RE.test(notePath)) return false;
164 return true;
165 }
166
167 /**
168 * @param {Record<string, unknown>|null|undefined} proposal
169 * @returns {Record<string, unknown>|null}
170 */
171 function parseProposalBodyObject(proposal) {
172 if (!proposal || typeof proposal !== 'object') return null;
173 try {
174 const parsed = JSON.parse(typeof proposal.body === 'string' ? proposal.body : '');
175 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
176 ? /** @type {Record<string, unknown>} */ (parsed)
177 : null;
178 } catch {
179 return null;
180 }
181 }
182
183 /**
184 * Scope from apply-path body order: task.scope → loop.scope → top-level scope.
185 * @param {Record<string, unknown>|null} body
186 * @returns {string}
187 */
188 export function extractTaskOrMediaScopeFromBody(body) {
189 if (!body || typeof body !== 'object') return '';
190 const task = body.task;
191 if (task && typeof task === 'object' && !Array.isArray(task)) {
192 const s = /** @type {Record<string, unknown>} */ (task).scope;
193 if (typeof s === 'string' && s.trim()) return s.trim();
194 }
195 const loop = body.loop;
196 if (loop && typeof loop === 'object' && !Array.isArray(loop)) {
197 const s = /** @type {Record<string, unknown>} */ (loop).scope;
198 if (typeof s === 'string' && s.trim()) return s.trim();
199 }
200 if (typeof body.scope === 'string' && body.scope.trim()) return body.scope.trim();
201 return '';
202 }
203
204 /**
205 * Path slug must be rewritten to a real proposal_id — never `pending` (approve-time admission).
206 * @param {string} path
207 * @param {RegExp} re
208 * @param {string|null|undefined} proposalId
209 * @returns {boolean}
210 */
211 function proposalMirrorPathAdmitted(path, re, proposalId) {
212 const notePath = String(path ?? '').trim().replace(/^\/+/, '');
213 const m = notePath.match(re);
214 if (!m) return false;
215 const slug = m[1];
216 if (!slug || slug === 'pending') return false;
217 if (proposalId != null && String(proposalId).trim()) {
218 return slug === String(proposalId).trim();
219 }
220 return true;
221 }
222
223 /**
224 * Create-time path shape for E1 — pending slug allowed until rewrite completes.
225 * @param {string} path
226 * @param {RegExp} re
227 * @returns {boolean}
228 */
229 function proposalMirrorPathShapeOk(path, re) {
230 const notePath = String(path ?? '').trim().replace(/^\/+/, '');
231 return re.test(notePath);
232 }
233
234 /**
235 * FINISH-COMPLETE-APPLY §FCA.4.1 — Tasks fingerprint (without assignee/author conjunct).
236 * @param {Record<string, unknown>|null|undefined} proposal
237 * @param {{ allowPendingPath?: boolean }} [opts]
238 * @returns {boolean}
239 */
240 export function matchesScoolingTaskFingerprint(proposal, opts = {}) {
241 if (!proposal || typeof proposal !== 'object') return false;
242 if (isDelegationSurfaceProposal(proposal)) return false;
243
244 const normalized = normalizeCanisterProposalForTaskPrecheck(proposal);
245 const isTask =
246 normalized != null ||
247 proposal.source === TASK_PROPOSAL_SOURCE ||
248 (typeof proposal.path === 'string' &&
249 proposal.path.replace(/^\/+/, '').startsWith('meta/tasks/proposals/'));
250 if (!isTask) return false;
251
252 const kind = String(
253 (normalized && normalized.task_meta && /** @type {{ proposal_kind?: string }} */ (normalized.task_meta)
254 .proposal_kind) ||
255 '',
256 ).trim();
257 if (!ADMITTED_TASK_KIND_SET.has(kind)) return false;
258
259 const pathOk = opts.allowPendingPath
260 ? proposalMirrorPathShapeOk(String(proposal.path ?? ''), SCOOLING_TASK_PROPOSAL_PATH_RE)
261 : proposalMirrorPathAdmitted(
262 String(proposal.path ?? ''),
263 SCOOLING_TASK_PROPOSAL_PATH_RE,
264 typeof proposal.proposal_id === 'string' ? proposal.proposal_id : null,
265 );
266 if (!pathOk) return false;
267
268 const externalRef = String(proposal.external_ref ?? '').trim();
269 if (!SCOOLING_TASK_EXTERNAL_REF_RE.test(externalRef)) return false;
270
271 const body = parseProposalBodyObject(proposal);
272 if (extractTaskOrMediaScopeFromBody(body) !== 'personal') return false;
273
274 return true;
275 }
276
277 /**
278 * FINISH-COMPLETE-APPLY §FCA.4.2 — Media fingerprint.
279 * @param {Record<string, unknown>|null|undefined} proposal
280 * @param {{ allowPendingPath?: boolean }} [opts]
281 * @returns {boolean}
282 */
283 export function matchesScoolingMediaFingerprint(proposal, opts = {}) {
284 if (!proposal || typeof proposal !== 'object') return false;
285 if (isDelegationSurfaceProposal(proposal)) return false;
286 if (proposal.source !== MEDIA_PROPOSAL_SOURCE) return false;
287
288 const body = parseProposalBodyObject(proposal);
289 const kindFromMeta =
290 proposal.media_meta &&
291 typeof proposal.media_meta === 'object' &&
292 typeof /** @type {{ proposal_kind?: string }} */ (proposal.media_meta).proposal_kind === 'string'
293 ? String(/** @type {{ proposal_kind: string }} */ (proposal.media_meta).proposal_kind).trim()
294 : '';
295 const kindFromBody =
296 body && typeof body.proposal_kind === 'string' ? body.proposal_kind.trim() : '';
297 const kind = kindFromMeta || kindFromBody;
298 if (!ADMITTED_MEDIA_KIND_SET.has(kind)) return false;
299
300 const pathOk = opts.allowPendingPath
301 ? proposalMirrorPathShapeOk(String(proposal.path ?? ''), SCOOLING_MEDIA_PROPOSAL_PATH_RE)
302 : proposalMirrorPathAdmitted(
303 String(proposal.path ?? ''),
304 SCOOLING_MEDIA_PROPOSAL_PATH_RE,
305 typeof proposal.proposal_id === 'string' ? proposal.proposal_id : null,
306 );
307 if (!pathOk) return false;
308
309 const externalRef = String(proposal.external_ref ?? '').trim();
310 if (!SCOOLING_MEDIA_EXTERNAL_REF_RE.test(externalRef)) return false;
311
312 if (extractTaskOrMediaScopeFromBody(body) !== 'personal') return false;
313
314 return true;
315 }
316
317 /**
318 * Scope for Flow proposals (§FWL.4.1): frontmatter.scope → body.flow.scope.
319 * @param {Record<string, unknown>|null|undefined} proposal
320 * @returns {string}
321 */
322 export function extractFlowScope(proposal) {
323 if (!proposal || typeof proposal !== 'object') return '';
324 const fm = proposal.frontmatter;
325 if (fm && typeof fm === 'object' && !Array.isArray(fm)) {
326 const s = /** @type {Record<string, unknown>} */ (fm).scope;
327 if (typeof s === 'string' && s.trim()) return s.trim();
328 }
329 const body = parseProposalBodyObject(proposal);
330 if (body) {
331 const flow = body.flow;
332 if (flow && typeof flow === 'object' && !Array.isArray(flow)) {
333 const s = /** @type {Record<string, unknown>} */ (flow).scope;
334 if (typeof s === 'string' && s.trim()) return s.trim();
335 }
336 }
337 return '';
338 }
339
340 /**
341 * FLOW-WRITE-LIVE §FWL.4.1 — Flow authoring fingerprint.
342 * Missing/empty `flow_meta.kind` is not admitted (do not mirror create-time
343 * `kind || 'new'` default in proposals-store).
344 *
345 * @param {Record<string, unknown>|null|undefined} proposal
346 * @returns {boolean}
347 */
348 export function matchesScoolingFlowFingerprint(proposal) {
349 if (!proposal || typeof proposal !== 'object') return false;
350 if (isDelegationSurfaceProposal(proposal)) return false;
351 if (proposal.source !== FLOW_PROPOSAL_SOURCE) return false;
352
353 const meta = proposal.flow_meta;
354 if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return false;
355 const kindRaw = /** @type {Record<string, unknown>} */ (meta).kind;
356 // Exact allowlist only — empty / missing / whitespace ⇒ refuse (no default-to-new).
357 if (typeof kindRaw !== 'string' || !kindRaw.trim()) return false;
358 const kind = kindRaw.trim();
359 if (!ADMITTED_FLOW_KIND_SET.has(kind)) return false;
360
361 const notePath = String(proposal.path ?? '').trim().replace(/^\/+/, '');
362 if (!SCOOLING_FLOW_MIRROR_PATH_RE.test(notePath)) return false;
363
364 const externalRef = String(proposal.external_ref ?? '').trim();
365 if (!SCOOLING_FLOW_EXTERNAL_REF_RE.test(externalRef)) return false;
366
367 if (extractFlowScope(proposal) !== 'personal') return false;
368
369 return true;
370 }
371
372 /**
373 * T5 positive admission for Tasks/Media/Flow (author used for task_assign self-assign).
374 * @param {Record<string, unknown>|null|undefined} proposal
375 * @param {string} authorActorId - already trimmed
376 * @param {{ allowPendingPath?: boolean }} [opts]
377 * @returns {boolean}
378 */
379 export function isAdmittedSeamSelfApplyFingerprint(proposal, authorActorId, opts = {}) {
380 if (matchesScoolingFlowFingerprint(proposal)) return true;
381 if (matchesScoolingMediaFingerprint(proposal, opts)) return true;
382 if (!matchesScoolingTaskFingerprint(proposal, opts)) return false;
383
384 const normalized = normalizeCanisterProposalForTaskPrecheck(proposal);
385 const kind = String(
386 (normalized && normalized.task_meta && /** @type {{ proposal_kind?: string }} */ (normalized.task_meta)
387 .proposal_kind) ||
388 '',
389 ).trim();
390 if (kind !== 'task_assign') return true;
391
392 const body = parseProposalBodyObject(proposal);
393 const assigneeRaw = body && body.assignee_ref != null ? body.assignee_ref : null;
394 if (typeof assigneeRaw !== 'string') return false;
395 const assignee = assigneeRaw.trim();
396 if (!assignee || !authorActorId) return false;
397 return assignee === authorActorId;
398 }
399
400 /**
401 * P6 — elevated severity or any auto-flag reasons → no self-apply.
402 * @param {Record<string, unknown>|null|undefined} proposal
403 * @returns {boolean}
404 */
405 export function isElevatedOrAutoFlagged(proposal) {
406 if (!proposal || typeof proposal !== 'object') return false;
407 if (String(proposal.review_severity ?? '').trim() === 'elevated') return true;
408 return parseAutoFlagReasons(proposal).length > 0;
409 }
410
411 /**
412 * Roles that may exercise personal self-apply when the full predicate holds.
413 * Hosted majority = member; Node Hub write role = editor.
414 *
415 * SEC-KN-3 / Pass 2 P6: self-apply is the learner's human review. Agent tokens
416 * (`tokenType: 'mcp_access'`, `actorKind: 'agent'`, or `humanActor: false`) are never eligible.
417 *
418 * @param {string} role
419 * @param {{
420 * humanActor?: boolean,
421 * tokenType?: string|null,
422 * actorKind?: string|null,
423 * }} [actor]
424 * @returns {boolean}
425 */
426 export function roleEligibleForPersonalSelfApply(role, actor = {}) {
427 if (actor.humanActor === false) return false;
428 if (String(actor.tokenType || '').trim() === 'mcp_access') return false;
429 if (String(actor.tokenType || '').trim() === 'agent_access') return false;
430 if (String(actor.actorKind || '').trim() === 'agent') return false;
431 const r = String(role || '').trim();
432 return r === 'member' || r === 'editor' || r === 'admin';
433 }
434
435 /**
436 * SEC-SEAM-1 / S3.1 — seam by construction from approve-time apply triggers.
437 * Fail-closed: any predicate throw classifies as seam (obligations imposed).
438 *
439 * @param {Record<string, unknown>|null|undefined} proposal
440 * @returns {boolean}
441 */
442 export function isSeamSurfaceProposal(proposal) {
443 try {
444 if (!proposal || typeof proposal !== 'object') return false;
445 if (normalizeCanisterProposalForTaskPrecheck(proposal) != null) return true;
446 if (normalizeCanisterProposalForDelegationPrecheck(proposal) != null) return true;
447 const source = proposal.source;
448 if (source === TASK_PROPOSAL_SOURCE) return true;
449 if (source === DELEGATION_PROPOSAL_SOURCE) return true;
450 if (source === MEDIA_PROPOSAL_SOURCE) return true;
451 if (source === FLOW_PROPOSAL_SOURCE) return true;
452 if (source === FLOW_CAPTURE_PROPOSAL_SOURCE) return true;
453 return false;
454 } catch {
455 return true;
456 }
457 }
458
459 /**
460 * SEC-SEAM-1 / S3.1 — delegation surface only (conditions 2 or 4).
461 * @param {Record<string, unknown>|null|undefined} proposal
462 * @returns {boolean}
463 */
464 export function isDelegationSurfaceProposal(proposal) {
465 try {
466 if (!proposal || typeof proposal !== 'object') return false;
467 if (normalizeCanisterProposalForDelegationPrecheck(proposal) != null) return true;
468 if (proposal.source === DELEGATION_PROPOSAL_SOURCE) return true;
469 return false;
470 } catch {
471 return true;
472 }
473 }
474
475 /**
476 * Trim opaque actor ids for exact comparison (case-sensitive).
477 * @param {unknown} value
478 * @returns {string}
479 */
480 function trimActorId(value) {
481 return typeof value === 'string' ? value.trim() : '';
482 }
483
484 /**
485 * SEC-SEAM-1 / S6 + FINISH-COMPLETE-APPLY T5 + FLOW-WRITE-LIVE — total refusal reason.
486 * Precedence is frozen in S6.1. Step 11 admits Tasks/Media/Flow fingerprints;
487 * flow_capture stays out; Delegation refused earlier.
488 *
489 * @param {{
490 * proposal: Record<string, unknown>|null|undefined,
491 * hasVaultWrite: boolean,
492 * partitionOwned: boolean,
493 * role?: string,
494 * humanActor?: boolean,
495 * tokenType?: string|null,
496 * actorKind?: string|null,
497 * authorActorId?: string|null,
498 * approverActorId?: string|null,
499 * sessionBound?: boolean,
500 * }} opts
501 * @returns {string|null}
502 */
503 export function personalSelfApplyRefusalReason(opts) {
504 const { proposal, hasVaultWrite, partitionOwned } = opts;
505 const authorActorId = trimActorId(opts.authorActorId);
506 const approverActorId = trimActorId(opts.approverActorId);
507
508 // 1 — S10 ineligible subject (internal-only over HTTP)
509 if (isSelfApplyIneligibleSub(approverActorId)) {
510 return 'SELF_APPLY_SUBJECT_INELIGIBLE';
511 }
512 // 2–3 — live order
513 if (!hasVaultWrite) return 'NOT_VAULT_WRITE';
514 if (!partitionOwned) return 'NOT_PARTITION_OWNED';
515 // 4 — role ineligible (role != null guard is load-bearing)
516 if (
517 opts.role != null &&
518 !roleEligibleForPersonalSelfApply(opts.role, {
519 humanActor: opts.humanActor,
520 tokenType: opts.tokenType,
521 actorKind: opts.actorKind,
522 })
523 ) {
524 return 'ROLE_NOT_ELIGIBLE';
525 }
526 // 5 — proposal missing
527 if (!proposal || typeof proposal !== 'object') return 'PROPOSAL_MISSING';
528 // 6 — status not proposed (absent status treated as proposed)
529 if (String(proposal.status ?? 'proposed').trim() !== 'proposed') {
530 return 'STATUS_NOT_PROPOSED';
531 }
532
533 const seam = isSeamSurfaceProposal(proposal);
534 // 7 — delegation refused unconditionally
535 if (isDelegationSurfaceProposal(proposal)) {
536 return 'SELF_APPLY_DELEGATION_REFUSED';
537 }
538 if (seam) {
539 // 8 — session binding
540 if (opts.sessionBound !== true) return 'SELF_APPLY_SESSION_BINDING_REQUIRED';
541 // 9 — author empty
542 if (!authorActorId) return 'SELF_APPLY_AUTHOR_UNVERIFIED';
543 // 10 — approver empty or author ≠ approver
544 if (!approverActorId || authorActorId !== approverActorId) {
545 return 'SELF_APPLY_AUTHOR_MISMATCH';
546 }
547 // 11 — T5 admission (Tasks/Media/Flow fingerprints; flow_capture stays out)
548 if (!isAdmittedSeamSelfApplyFingerprint(proposal, authorActorId)) {
549 return 'SELF_APPLY_NOT_ADMITTED';
550 }
551 if (isElevatedOrAutoFlagged(proposal)) return 'ELEVATED_OR_AUTO_FLAGGED';
552 return null;
553 }
554
555 // 12 — fingerprint
556 if (!matchesScoolingReviewTrayFingerprint(proposal)) return 'FINGERPRINT_MISMATCH';
557 // 13 — elevated / auto-flagged
558 if (isElevatedOrAutoFlagged(proposal)) return 'ELEVATED_OR_AUTO_FLAGGED';
559 // 14 — class holds
560 return null;
561 }
562
563 /**
564 * Full personal self-apply class check (P1–P8 + SEC-SEAM-1 + T5).
565 * Implemented as `personalSelfApplyRefusalReason(opts) === null` so the boolean
566 * and named-reason paths can never diverge (S6 / V5).
567 *
568 * @param {Parameters<typeof personalSelfApplyRefusalReason>[0]} opts
569 * @returns {boolean}
570 */
571 export function isPersonalSelfApplyClass(opts) {
572 return personalSelfApplyRefusalReason(opts) === null;
573 }
574
575 /**
576 * Whether create-time E1 may self-pass this body for an admitted class.
577 * Review-tray: fingerprint only (unchanged). Tasks/Media/Flow: fingerprint + session/author gates.
578 *
579 * @param {Record<string, unknown>} body
580 * @param {{
581 * evaluatedBy?: string,
582 * sessionBound?: boolean,
583 * authorActorId?: string|null,
584 * }} audit
585 * @returns {boolean}
586 */
587 function e1FingerprintEligible(body, audit) {
588 if (matchesScoolingReviewTrayFingerprint(body)) return true;
589 // Refuse to self-pass when sessionBound/author gates would fail at approve (P3 / §FCA.4.0 E1).
590 if (audit.sessionBound !== true) return false;
591 const author = trimActorId(audit.authorActorId ?? audit.evaluatedBy);
592 if (!author) return false;
593 return isAdmittedSeamSelfApplyFingerprint(body, author, { allowPendingPath: true });
594 }
595
596 /**
597 * E1 — after policy + review-trigger augmentation, self-satisfy evaluation for the class.
598 * Elevated / auto-flagged proposals are left untouched (stay pending when gate/triggers require it).
599 * T5: also stamps admitted Task/Media/Flow fingerprints when sessionBound + author hold.
600 *
601 * @param {Record<string, unknown>} body - post-trigger create body
602 * @param {{
603 * evaluatedBy?: string,
604 * evaluatedAt?: string,
605 * sessionBound?: boolean,
606 * authorActorId?: string|null,
607 * }} [audit]
608 * @returns {Record<string, unknown>}
609 */
610 export function applyPersonalSelfApplyEvaluationE1(body, audit = {}) {
611 if (!body || typeof body !== 'object' || Buffer.isBuffer(body)) return body;
612 if (!e1FingerprintEligible(body, audit)) return body;
613 if (isElevatedOrAutoFlagged(body)) {
614 // P6 fail-closed: never leave a forged `passed` on elevated / auto-flagged class rows.
615 if (String(body.evaluation_status ?? '').trim() === 'passed') {
616 const cleared = { ...body, evaluation_status: 'pending' };
617 delete cleared.evaluated_by;
618 delete cleared.evaluated_at;
619 return cleared;
620 }
621 return body;
622 }
623 // SEC-KN-2: evaluated_by / evaluated_at come only from server audit — never from body.
624 const evaluatedBy =
625 typeof audit.evaluatedBy === 'string' && audit.evaluatedBy.trim()
626 ? audit.evaluatedBy.trim().slice(0, 256)
627 : '';
628 const evaluatedAt =
629 typeof audit.evaluatedAt === 'string' && audit.evaluatedAt.trim()
630 ? audit.evaluatedAt.trim()
631 : new Date().toISOString();
632 const next = {
633 ...body,
634 evaluation_status: 'passed',
635 evaluated_at: evaluatedAt,
636 };
637 if (evaluatedBy) next.evaluated_by = evaluatedBy;
638 else delete next.evaluated_by;
639 return next;
640 }
641
642 /**
643 * Whether approve RBAC may allow this actor via personal self-apply (not admin/evaluator path).
644 * Same boolean contract as before; equals `personalSelfApplyRefusalReason(opts) === null` (V5).
645 *
646 * @param {Parameters<typeof personalSelfApplyRefusalReason>[0]} opts
647 * @returns {boolean}
648 */
649 export function personalSelfApplyAllowsApprove(opts) {
650 return personalSelfApplyRefusalReason(opts) === null;
651 }
File History 1 commit
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 42 days ago