@brainervirus/workit-core 1.0.10 → 1.0.12

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.
@@ -14,6 +14,7 @@ import {
14
14
  } from "./task-contract";
15
15
  import { WorkitCore } from "./task-engine";
16
16
  import { TaskStore } from "./task-store";
17
+ import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
17
18
  import * as z from "zod";
18
19
  import { execFileSync } from "node:child_process";
19
20
 
@@ -323,33 +324,58 @@ export const priorExternalAction = (
323
324
  /** Parse a plan-commit authorization descriptor (one approved list per plan). */
324
325
  export const planCommitDescriptor = (
325
326
  content: string,
326
- ): { steps: string[]; branch: string } | null => {
327
+ ): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
327
328
  try {
328
329
  const value = JSON.parse(content) as {
329
330
  operation?: unknown;
330
- payload?: { plan_steps?: unknown; plan_branch?: unknown };
331
+ payload?: {
332
+ plan_steps?: unknown;
333
+ plan_branch?: unknown;
334
+ resolved?: { head?: unknown; branch?: unknown };
335
+ };
331
336
  };
332
337
  if (value.operation !== "git.commit" || !value.payload) return null;
333
- const steps = value.payload.plan_steps;
338
+ const steps = normalizeChainSteps(value.payload.plan_steps);
334
339
  const branch = value.payload.plan_branch;
335
- if (
336
- !Array.isArray(steps) ||
337
- steps.some((step) => typeof step !== "string" || !step) ||
338
- typeof branch !== "string" ||
339
- !branch
340
- )
341
- return null;
342
- return { steps: steps as string[], branch };
340
+ if (!steps || typeof branch !== "string" || !branch) return null;
341
+ const head = value.payload.resolved?.head;
342
+ return { steps, branch, snapshotHead: typeof head === "string" ? head : null };
343
343
  } catch {
344
344
  return null;
345
345
  }
346
346
  };
347
347
 
348
+ /**
349
+ * Chain lease: the recorded head must still be an ancestor of the current
350
+ * HEAD — history only moved forward. Rebase/reset underneath an approved
351
+ * chain invalidates it instead of executing on rewritten history.
352
+ */
353
+ export const chainLeaseValid = (store: TaskStore, snapshotHead: string | null): boolean => {
354
+ if (!snapshotHead) return true;
355
+ try {
356
+ const head = execFileSync("git", ["rev-parse", "HEAD"], {
357
+ cwd: store.root,
358
+ encoding: "utf8",
359
+ stdio: ["pipe", "pipe", "pipe"],
360
+ }).trim();
361
+ if (head === snapshotHead) return true;
362
+ execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
363
+ cwd: store.root,
364
+ encoding: "utf8",
365
+ stdio: ["pipe", "pipe", "pipe"],
366
+ });
367
+ return true;
368
+ } catch {
369
+ return false;
370
+ }
371
+ };
372
+
348
373
  export type PlanCommitAuthorization = {
349
374
  task: TaskRecord;
350
375
  entry: Entry<Decision>;
351
- steps: string[];
376
+ steps: ChainStep[];
352
377
  branch: string;
378
+ snapshotHead: string | null;
353
379
  completed: string[];
354
380
  };
355
381
 
@@ -357,7 +383,10 @@ export type PlanCommitAuthorization = {
357
383
  * Find the single active plan-commit authorization whose next unconsumed step
358
384
  * is this exact commit message. Consumption order is enforced so each listed
359
385
  * commit executes once, in plan order; unlisted or replayed messages find no
360
- * authorization and require a fresh exact approval.
386
+ * authorization and require a fresh exact approval. Typed chain positions
387
+ * never match messages (a pathological message falls through to a fresh
388
+ * approval instead of consuming the wrong step), and a rewritten history
389
+ * breaks the lease.
361
390
  */
362
391
  export const approvedPlanCommit = (
363
392
  store: TaskStore,
@@ -395,8 +424,19 @@ export const approvedPlanCommit = (
395
424
  const completed =
396
425
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
397
426
  [];
398
- if (plan.steps[completed.length] !== message) return [];
399
- return [{ task, entry, steps: plan.steps, branch: plan.branch, completed: [...completed] }];
427
+ const next = plan.steps[completed.length];
428
+ if (!next || next.kind !== "commit" || next.message !== message) return [];
429
+ if (!chainLeaseValid(store, plan.snapshotHead)) return [];
430
+ return [
431
+ {
432
+ task,
433
+ entry,
434
+ steps: plan.steps,
435
+ branch: plan.branch,
436
+ snapshotHead: plan.snapshotHead,
437
+ completed: [...completed],
438
+ },
439
+ ];
400
440
  });
401
441
  });
402
442
  if (matches.length !== 1)
@@ -409,6 +449,80 @@ export const approvedPlanCommit = (
409
449
  return success(workspace.data.revision, null, matches[0]);
410
450
  };
411
451
 
452
+ export type ChainStepQuery =
453
+ | { operation: "git.branch_setup"; target: string }
454
+ | { operation: "hosting.pull_request" };
455
+
456
+ /**
457
+ * Match one branch or PR step of a chain authorization: same session-bound
458
+ * search as plan commits, next unconsumed step of the right kind, lease
459
+ * intact. Commit steps stay on the plan path; this covers the steps plans
460
+ * could never express.
461
+ */
462
+ export const approvedChainStep = (
463
+ store: TaskStore,
464
+ host: string,
465
+ actor: string,
466
+ query: ChainStepQuery,
467
+ ): Result<PlanCommitAuthorization> => {
468
+ const listed = store.listTasks();
469
+ const workspace = store.readWorkspace();
470
+ if (!listed.ok || !workspace.ok)
471
+ return failure("storage_error", "external action state is unavailable");
472
+ if (!workspace.data) return failure("not_found", "workspace not found");
473
+ const matches = listed.data.flatMap((task) => {
474
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
475
+ if (
476
+ task.intent.provenance.session?.kind !== "host" ||
477
+ task.intent.provenance.session.host !== host ||
478
+ task.intent.provenance.session.handle !== actor
479
+ )
480
+ return [];
481
+ return task.decisions.flatMap((entry) => {
482
+ const decision = entry.data;
483
+ if (
484
+ decision.purpose !== "action" ||
485
+ decision.response !== "approved" ||
486
+ decision.revoked !== null
487
+ )
488
+ return [];
489
+ if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
490
+ return [];
491
+ const plan = planCommitDescriptor(decision.binding.approvedContent);
492
+ if (!plan) return [];
493
+ const completed =
494
+ task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
495
+ [];
496
+ const next = plan.steps[completed.length];
497
+ if (!next) return [];
498
+ if (query.operation === "git.branch_setup") {
499
+ if (next.kind !== "branch" || next.target !== query.target) return [];
500
+ } else if (next.kind !== "pr") {
501
+ return [];
502
+ }
503
+ if (!chainLeaseValid(store, plan.snapshotHead)) return [];
504
+ return [
505
+ {
506
+ task,
507
+ entry,
508
+ steps: plan.steps,
509
+ branch: plan.branch,
510
+ snapshotHead: plan.snapshotHead,
511
+ completed: [...completed],
512
+ },
513
+ ];
514
+ });
515
+ });
516
+ if (matches.length !== 1)
517
+ return failure(
518
+ "permission_denied",
519
+ matches.length === 0
520
+ ? "no chain authorization matches this step"
521
+ : "multiple chain authorizations match this step",
522
+ );
523
+ return success(workspace.data.revision, null, matches[0]);
524
+ };
525
+
412
526
  export const commitMessageFromDescriptor = (operation: string): string | undefined => {
413
527
  try {
414
528
  const descriptor = JSON.parse(operation) as {
@@ -423,6 +537,69 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
423
537
  }
424
538
  };
425
539
 
540
+ /** Resolve a chain authorization into the exact binding an adapter needs
541
+ * to run one branch or PR step under the approved chain. Commit steps stay
542
+ * on the plan path; this covers the steps plans could never express. */
543
+ export const chainStepBinding = (
544
+ store: TaskStore,
545
+ host: string,
546
+ actor: string,
547
+ operation: string,
548
+ ): {
549
+ taskId: Id;
550
+ decisionId: Id;
551
+ binding: Decision["binding"];
552
+ expectedRevision: Revision;
553
+ expectedWorkspaceRevision: Revision;
554
+ step: string;
555
+ } | null => {
556
+ let descriptor: { operation?: unknown; payload?: Record<string, unknown> };
557
+ try {
558
+ descriptor = JSON.parse(operation) as {
559
+ operation?: unknown;
560
+ payload?: Record<string, unknown>;
561
+ };
562
+ } catch {
563
+ return null;
564
+ }
565
+ const query: ChainStepQuery | null =
566
+ descriptor.operation === "git.branch_setup" &&
567
+ typeof descriptor.payload?.target_branch === "string"
568
+ ? { operation: "git.branch_setup", target: descriptor.payload.target_branch as string }
569
+ : descriptor.operation === "hosting.pull_request"
570
+ ? { operation: "hosting.pull_request" }
571
+ : null;
572
+ if (!query) return null;
573
+ const chain = approvedChainStep(store, host, actor, query);
574
+ if (!chain.ok) return null;
575
+ // PR steps run on the chain branch; the branch step itself creates it.
576
+ if (query.operation === "hosting.pull_request") {
577
+ let branch: string;
578
+ try {
579
+ branch = execFileSync("git", ["branch", "--show-current"], {
580
+ cwd: store.root,
581
+ encoding: "utf8",
582
+ stdio: ["pipe", "pipe", "pipe"],
583
+ }).trim();
584
+ } catch {
585
+ return null;
586
+ }
587
+ if (!branch || branch !== chain.data.branch) return null;
588
+ }
589
+ const workspace = store.readWorkspace();
590
+ if (!workspace.ok || !workspace.data) return null;
591
+ const next = chain.data.steps[chain.data.completed.length];
592
+ if (!next) return null;
593
+ return {
594
+ taskId: chain.data.task.id,
595
+ decisionId: chain.data.entry.id,
596
+ binding: chain.data.entry.data.binding,
597
+ expectedRevision: chain.data.task.revision,
598
+ expectedWorkspaceRevision: workspace.data.revision,
599
+ step: chainStepKey(next),
600
+ };
601
+ };
602
+
426
603
  /** Resolve a plan-commit authorization into the exact binding an adapter needs
427
604
  * to run one listed commit under the approved list. */
428
605
  export const planCommitBinding = (
@@ -472,24 +649,131 @@ export const approvedExternalAction = (
472
649
  operation: string,
473
650
  ) => {
474
651
  const selected = externalActionState(store, host, actor, operation);
475
- if (!selected.ok) return selected;
476
- if (selected.data.entry.data.consumption !== null)
652
+ if (selected.ok) {
653
+ if (selected.data.entry.data.consumption !== null)
654
+ return failure(
655
+ selected.data.entry.data.consumption.state === "uncertain"
656
+ ? "external_outcome_unknown"
657
+ : "permission_denied",
658
+ selected.data.entry.data.consumption.state === "uncertain"
659
+ ? "previous external action outcome is unknown"
660
+ : "external action was already settled",
661
+ );
662
+ return selected;
663
+ }
664
+ // Intent carry for branch setup: an approval binds (target, base) intent,
665
+ // so unrelated HEAD or dirt moves between approval and execution re-resolve
666
+ // instead of demanding re-approval. Remote, existence, and target drift
667
+ // still fail closed through priorResolvedDrift.
668
+ const intent = approvedBranchSetupIntent(store, host, actor, operation);
669
+ if (intent.ok) {
670
+ if (intent.data.entry.data.consumption !== null)
671
+ return failure(
672
+ intent.data.entry.data.consumption.state === "uncertain"
673
+ ? "external_outcome_unknown"
674
+ : "permission_denied",
675
+ intent.data.entry.data.consumption.state === "uncertain"
676
+ ? "previous external action outcome is unknown"
677
+ : "external action was already settled",
678
+ );
679
+ return intent;
680
+ }
681
+ return selected;
682
+ };
683
+
684
+ /** Intent identity for a branch-setup descriptor: target and base bind the
685
+ * effect; head and dirt are execution-time state that re-resolves. */
686
+ export const branchSetupIntent = (
687
+ descriptor: string,
688
+ ): { target: string; base: string; remoteBase: string | null; targetExists: boolean } | null => {
689
+ try {
690
+ const value = JSON.parse(descriptor) as {
691
+ operation?: unknown;
692
+ payload?: {
693
+ target_branch?: unknown;
694
+ resolved?: {
695
+ base_branch?: unknown;
696
+ remote_base?: unknown;
697
+ target_exists?: unknown;
698
+ };
699
+ };
700
+ };
701
+ if (value.operation !== "git.branch_setup" || !value.payload) return null;
702
+ const { target_branch, resolved } = value.payload;
703
+ if (typeof target_branch !== "string" || !target_branch || !resolved) return null;
704
+ if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
705
+ return null;
706
+ return {
707
+ target: target_branch,
708
+ base: resolved.base_branch,
709
+ remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
710
+ targetExists: resolved.target_exists,
711
+ };
712
+ } catch {
713
+ return null;
714
+ }
715
+ };
716
+
717
+ /** Find the single unconsumed branch-setup approval with the same intent as
718
+ * the current descriptor, regardless of head or dirt moves since approval. */
719
+ export const approvedBranchSetupIntent = (
720
+ store: TaskStore,
721
+ host: string,
722
+ actor: string,
723
+ operation: string,
724
+ ) => {
725
+ const current = branchSetupIntent(operation);
726
+ if (!current) return failure("permission_denied", "no approved action is bound to this session");
727
+ const listed = store.listTasks();
728
+ const workspace = store.readWorkspace();
729
+ if (!listed.ok || !workspace.ok)
730
+ return failure("storage_error", "external action state is unavailable");
731
+ if (!workspace.data) return failure("not_found", "workspace not found");
732
+ const candidates = listed.data.flatMap((task) => {
733
+ if (
734
+ task.status !== "active" ||
735
+ task.workspaceId !== workspace.data!.id ||
736
+ task.intent.provenance.session?.kind !== "host" ||
737
+ task.intent.provenance.session.host !== host ||
738
+ task.intent.provenance.session.handle !== actor
739
+ )
740
+ return [];
741
+ return task.decisions
742
+ .filter(
743
+ (entry) =>
744
+ entry.data.purpose === "action" &&
745
+ entry.data.response === "approved" &&
746
+ entry.data.revoked === null,
747
+ )
748
+ .map((entry) => ({ task, entry }));
749
+ });
750
+ const matches = candidates.filter(({ entry }) => {
751
+ const intent = branchSetupIntent(entry.data.binding.approvedContent);
752
+ return (
753
+ intent !== null &&
754
+ intent.target === current.target &&
755
+ intent.base === current.base &&
756
+ intent.remoteBase === current.remoteBase &&
757
+ intent.targetExists === current.targetExists
758
+ );
759
+ });
760
+ if (matches.length !== 1)
477
761
  return failure(
478
- selected.data.entry.data.consumption.state === "uncertain"
479
- ? "external_outcome_unknown"
480
- : "permission_denied",
481
- selected.data.entry.data.consumption.state === "uncertain"
482
- ? "previous external action outcome is unknown"
483
- : "external action was already settled",
762
+ "permission_denied",
763
+ matches.length === 0
764
+ ? "no approved action is bound to this session"
765
+ : "multiple approved actions match this session",
484
766
  );
485
- return selected;
767
+ return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
486
768
  };
487
769
 
488
770
  /**
489
771
  * Re-resolve guard: when a prior approval exists for the same requested
490
- * operation/payload, the freshly resolved baseline (source state and remote
491
- * base) must still match the approved descriptor. Drift returns not_started so
492
- * the agent re-presents the current state instead of executing a stale effect.
772
+ * operation/payload, the intent baseline must still match. For branch setup
773
+ * only (target, base, remote base, existence) bind the effect — head and
774
+ * dirt moves re-resolve instead of demanding re-approval. Drift names the
775
+ * moved element and returns not_started so the agent re-presents the
776
+ * current state instead of executing a stale effect.
493
777
  */
494
778
  export const priorResolvedDrift = (
495
779
  store: TaskStore,
@@ -511,6 +795,38 @@ export const priorResolvedDrift = (
511
795
  return success(null, null, null);
512
796
  }
513
797
  if (baseline === undefined || baseline === null) return success(null, null, null);
798
+ if (operation === "git.branch_setup") {
799
+ const before = branchSetupIntent(prior.data.entry.data.binding.approvedContent);
800
+ let after: ReturnType<typeof branchSetupIntent> = null;
801
+ try {
802
+ after = branchSetupIntent(
803
+ JSON.stringify({
804
+ operation,
805
+ payload: { ...(payload as Record<string, unknown>), resolved: currentResolved },
806
+ }),
807
+ );
808
+ } catch {
809
+ after = null;
810
+ }
811
+ if (before && after) {
812
+ const moved =
813
+ before.target !== after.target
814
+ ? "target_branch"
815
+ : before.base !== after.base
816
+ ? "base_branch"
817
+ : before.remoteBase !== after.remoteBase
818
+ ? "remote_base"
819
+ : before.targetExists !== after.targetExists
820
+ ? "target_exists"
821
+ : null;
822
+ if (moved === null) return success(null, null, null);
823
+ return failure(
824
+ "invalid_input",
825
+ `repository state changed since approval (${moved} moved); show the current state and approve the action again`,
826
+ { outcome: "not_started", operation },
827
+ );
828
+ }
829
+ }
514
830
  if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
515
831
  return failure(
516
832
  "invalid_input",
@@ -17,12 +17,14 @@ type MethodDefinition = {
17
17
 
18
18
  export const METHODS: Readonly<Record<MethodId, MethodDefinition>> = {
19
19
  "workit-challenge": { dimensions: ["challenge", "decisions"] },
20
- "workit-behavioral-tdd": { dimensions: ["testing"] },
21
- "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review"] },
20
+ "workit-behavioral-tdd": { dimensions: ["testing"], ruleIds: ["mechanical-existing-checks"] },
21
+ "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review", "self-review"] },
22
22
  "workit-plan": { dimensions: ["artifacts", "continuity"] },
23
23
  "workit-implement": { dimensions: ["delegation"] },
24
- "workit-debug": { ruleIds: ["root-cause-investigation"] },
25
- "workit-handoff": { ruleIds: ["durable-handoff"] },
24
+ // workit-debug and workit-handoff stay slash-invoked: no generated rule
25
+ // routes to them, so they never appear spuriously in method selection.
26
+ "workit-debug": {},
27
+ "workit-handoff": {},
26
28
  "workit-deslop": { ruleIds: ["pre-pr-cleanup"] },
27
29
  };
28
30
 
@@ -33,12 +35,10 @@ export type SelectedMethod = {
33
35
  };
34
36
 
35
37
  const methodMatches = (
36
- id: MethodId,
37
38
  definition: MethodDefinition,
38
39
  requirement: Policy["requirements"][number],
39
40
  ): boolean =>
40
- (definition.dimensions?.includes(requirement.dimension) === true &&
41
- !(id === "workit-review" && requirement.ruleId === "self-review")) ||
41
+ definition.dimensions?.includes(requirement.dimension) === true ||
42
42
  definition.ruleIds?.includes(requirement.ruleId) === true;
43
43
 
44
44
  const assuranceFor = (
@@ -71,7 +71,7 @@ const assuranceFor = (
71
71
  export function selectMethods(policy: Policy, capabilities: Capability[]): SelectedMethod[] {
72
72
  return (Object.entries(METHODS) as [MethodId, MethodDefinition][]).flatMap(([id, definition]) => {
73
73
  const requirements = policy.requirements.filter((requirement) =>
74
- methodMatches(id, definition, requirement),
74
+ methodMatches(definition, requirement),
75
75
  );
76
76
  if (requirements.length === 0) return [];
77
77
  const capability = assuranceFor(id, definition, capabilities);
@@ -1,3 +1,5 @@
1
+ import { TaskStore } from "./task-store";
2
+
1
3
  export type ShellRouteIntent = {
2
4
  route: "git.branch_setup" | "hosting.pull_request";
3
5
  guidance: string;
@@ -49,3 +51,29 @@ export function shellRouteIntent(command: string): ShellRouteIntent | null {
49
51
  }
50
52
  return null;
51
53
  }
54
+
55
+ /**
56
+ * Session-scoped enforcement: the denial protects live Workit coordination,
57
+ * so it fires only where an active or paused task exists. Any filesystem or
58
+ * storage failure means "no live task" — never deny on uncertainty.
59
+ */
60
+ export function hasLiveWorkitTask(dir: string): boolean {
61
+ try {
62
+ if (typeof dir !== "string" || dir === "") return false;
63
+ const listed = new TaskStore(dir).listTasks();
64
+ if (!listed.ok) return false;
65
+ return listed.data.some((task) => task.status === "active" || task.status === "paused");
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Single core gate for the shell route denial. Adapters pass their
73
+ * session/project directory and delete local deny duplication; outside
74
+ * attributed sessions the command is silently allowed.
75
+ */
76
+ export function shouldDenyShellRoute(dir: string, command: string): ShellRouteIntent | null {
77
+ if (!hasLiveWorkitTask(dir)) return null;
78
+ return shellRouteIntent(command);
79
+ }
@@ -0,0 +1,33 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ /** sha256 of the exact bytes, lowercase hex. */
5
+ export const sha256Hex = (bytes: string | Uint8Array): string =>
6
+ createHash("sha256").update(bytes).digest("hex");
7
+
8
+ /** sha256 of a bundle file's bytes, or null when it cannot be read. */
9
+ export const bundleHashOfFile = (file: string): string | null => {
10
+ try {
11
+ return sha256Hex(readFileSync(file));
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ // Segments of package-manager ephemeral caches (pnpm dlx, npx, pacquet).
18
+ // Installs resolving through these break when the cache is cleared; one
19
+ // table here replaces the per-callsite substring lists.
20
+ const EPHEMERAL_CACHE_SEGMENTS = [
21
+ "/caches/pnpm/dlx/",
22
+ "/.cache/pnpm/dlx/",
23
+ "/_npx/",
24
+ "/.pacquet/",
25
+ "/library/caches/pnpm/",
26
+ ];
27
+
28
+ /** True when the entry resolves through a package-manager ephemeral cache. */
29
+ export const isEphemeralCachePath = (entry: string): boolean => {
30
+ if (!entry) return false;
31
+ const normalized = entry.replaceAll("\\", "/").toLowerCase();
32
+ return EPHEMERAL_CACHE_SEGMENTS.some((segment) => normalized.includes(segment));
33
+ };
@@ -147,6 +147,113 @@ export const refSchema = z.discriminatedUnion("kind", [
147
147
  ]);
148
148
  export type Ref = z.infer<typeof refSchema>;
149
149
 
150
+ /** Maps one reference during record traversal. Returning null marks the
151
+ * reference unportable: arrays drop it, nullable fields null it, and
152
+ * non-nullable positions propagate the drop to their owner. */
153
+ export type RefMap = (ref: Ref) => Ref | null;
154
+
155
+ type RewriteOutcome = { drop: boolean; value?: unknown };
156
+
157
+ const rewriteUnknown = (value: unknown, map: RefMap): RewriteOutcome => {
158
+ if (Array.isArray(value)) {
159
+ const out: unknown[] = [];
160
+ for (const item of value) {
161
+ const rewritten = rewriteUnknown(item, map);
162
+ if (!rewritten.drop) out.push(rewritten.value);
163
+ }
164
+ return { drop: false, value: out };
165
+ }
166
+ if (typeof value === "object" && value !== null) {
167
+ if (refSchema.safeParse(value).success) {
168
+ const mapped = map(value as Ref);
169
+ return mapped === null ? { drop: true } : { drop: false, value: mapped };
170
+ }
171
+ const out: Record<string, unknown> = {};
172
+ for (const [key, item] of Object.entries(value)) {
173
+ const rewritten = rewriteUnknown(item, map);
174
+ out[key] = rewritten.drop ? null : rewritten.value;
175
+ }
176
+ return { drop: false, value: out };
177
+ }
178
+ return { drop: false, value };
179
+ };
180
+
181
+ const rewriteNode = (value: unknown, schema: z.ZodType, map: RefMap): RewriteOutcome => {
182
+ // Note: this zod version's classic accessors return core types, hence the
183
+ // casts below. Runtime behavior is identical; only the annotations differ.
184
+ if (
185
+ typeof value === "object" &&
186
+ value !== null &&
187
+ !Array.isArray(value) &&
188
+ refSchema.safeParse(value).success
189
+ ) {
190
+ const mapped = map(value as Ref);
191
+ return mapped === null ? { drop: true } : { drop: false, value: mapped };
192
+ }
193
+ let node: z.ZodType = schema;
194
+ while (
195
+ node instanceof z.ZodNullable ||
196
+ node instanceof z.ZodOptional ||
197
+ node instanceof z.ZodDefault
198
+ ) {
199
+ node = node.unwrap() as z.ZodType;
200
+ }
201
+ if (node === refSchema) {
202
+ if (!refSchema.safeParse(value).success) return { drop: false, value };
203
+ const mapped = map(value as Ref);
204
+ return mapped === null ? { drop: true } : { drop: false, value: mapped };
205
+ }
206
+ if (node instanceof z.ZodArray) {
207
+ if (!Array.isArray(value)) return { drop: false, value };
208
+ const out: unknown[] = [];
209
+ for (const item of value) {
210
+ const rewritten = rewriteNode(item, node.element as z.ZodType, map);
211
+ if (!rewritten.drop) out.push(rewritten.value);
212
+ }
213
+ return { drop: false, value: out };
214
+ }
215
+ if (node instanceof z.ZodObject) {
216
+ if (typeof value !== "object" || value === null || Array.isArray(value))
217
+ return { drop: false, value };
218
+ const shape = node.shape as Record<string, z.ZodType>;
219
+ const record = value as Record<string, unknown>;
220
+ const out: Record<string, unknown> = {};
221
+ for (const [key, item] of Object.entries(record)) {
222
+ if (!(key in shape)) {
223
+ const scrubbed = rewriteUnknown(item, map);
224
+ out[key] = scrubbed.drop ? null : scrubbed.value;
225
+ continue;
226
+ }
227
+ const child = shape[key];
228
+ const rewritten = rewriteNode(item, child, map);
229
+ if (rewritten.drop) {
230
+ if (child instanceof z.ZodOptional || child instanceof z.ZodDefault) continue;
231
+ if (child instanceof z.ZodNullable) {
232
+ out[key] = null;
233
+ continue;
234
+ }
235
+ return { drop: true };
236
+ }
237
+ out[key] = rewritten.value;
238
+ }
239
+ return { drop: false, value: out };
240
+ }
241
+ return { drop: false, value };
242
+ };
243
+
244
+ /**
245
+ * Schema-driven reference rewrite over an already-validated record subtree.
246
+ * Every `Ref` anywhere in the value is mapped; unportable references are
247
+ * dropped from arrays, nulled in nullable fields, and propagated upward
248
+ * anywhere else — so a new ref-bearing field can never silently slip
249
+ * through export or import. Unknown (schema-absent) keys are scrubbed the
250
+ * same way. Returns null only when the root itself is an unportable ref.
251
+ */
252
+ export const rewriteRecordRefs = (value: unknown, schema: z.ZodType, map: RefMap): unknown => {
253
+ const rewritten = rewriteNode(value, schema, map);
254
+ return rewritten.drop ? null : rewritten.value;
255
+ };
256
+
150
257
  export const provenanceSchema = z
151
258
  .object({
152
259
  kind: z.enum(["host_observed", "agent_reported", "imported"]),
@@ -455,10 +562,11 @@ export const decisionSchema = z
455
562
  approvedContent: text,
456
563
  displayed: text.optional(),
457
564
  contentRefs: z.array(refSchema),
565
+ statedChoice: z.object({ ref: text, text: text }).strict().optional(),
458
566
  })
459
567
  .strict(),
460
568
  digest,
461
- response: z.enum(["approved", "rejected"]),
569
+ response: z.enum(["approved", "rejected", "stated"]),
462
570
  requirementIds: z.array(digest),
463
571
  revoked: z.object({ at: utc, reason: text }).strict().nullable(),
464
572
  consumption: z