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