instar 1.3.877 → 1.3.879

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.
Files changed (65) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/init.js +8 -0
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/core/ApprenticeshipMatrixAcceptance.d.ts +124 -0
  5. package/dist/core/ApprenticeshipMatrixAcceptance.d.ts.map +1 -0
  6. package/dist/core/ApprenticeshipMatrixAcceptance.js +240 -0
  7. package/dist/core/ApprenticeshipMatrixAcceptance.js.map +1 -0
  8. package/dist/core/ApprenticeshipProgram.d.ts +25 -3
  9. package/dist/core/ApprenticeshipProgram.d.ts.map +1 -1
  10. package/dist/core/ApprenticeshipProgram.js +72 -25
  11. package/dist/core/ApprenticeshipProgram.js.map +1 -1
  12. package/dist/core/ApprenticeshipStallGate.d.ts +297 -0
  13. package/dist/core/ApprenticeshipStallGate.d.ts.map +1 -0
  14. package/dist/core/ApprenticeshipStallGate.js +935 -0
  15. package/dist/core/ApprenticeshipStallGate.js.map +1 -0
  16. package/dist/core/MessagingToneGate.d.ts +33 -1
  17. package/dist/core/MessagingToneGate.d.ts.map +1 -1
  18. package/dist/core/MessagingToneGate.js +47 -9
  19. package/dist/core/MessagingToneGate.js.map +1 -1
  20. package/dist/core/PostUpdateMigrator.d.ts +8 -0
  21. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  22. package/dist/core/PostUpdateMigrator.js +75 -0
  23. package/dist/core/PostUpdateMigrator.js.map +1 -1
  24. package/dist/core/apprenticeshipStallGate.worker.d.ts +13 -0
  25. package/dist/core/apprenticeshipStallGate.worker.d.ts.map +1 -0
  26. package/dist/core/apprenticeshipStallGate.worker.js +23 -0
  27. package/dist/core/apprenticeshipStallGate.worker.js.map +1 -0
  28. package/dist/core/types.d.ts +13 -0
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/core/types.js.map +1 -1
  31. package/dist/monitoring/SelfUnblockChecklist.d.ts +1 -1
  32. package/dist/monitoring/SelfUnblockChecklist.d.ts.map +1 -1
  33. package/dist/monitoring/SelfUnblockChecklist.js +2 -0
  34. package/dist/monitoring/SelfUnblockChecklist.js.map +1 -1
  35. package/dist/monitoring/SelfUnblockProbeProviders.d.ts +19 -1
  36. package/dist/monitoring/SelfUnblockProbeProviders.d.ts.map +1 -1
  37. package/dist/monitoring/SelfUnblockProbeProviders.js +187 -2
  38. package/dist/monitoring/SelfUnblockProbeProviders.js.map +1 -1
  39. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  40. package/dist/monitoring/guardManifest.js +22 -0
  41. package/dist/monitoring/guardManifest.js.map +1 -1
  42. package/dist/scaffold/templates.d.ts.map +1 -1
  43. package/dist/scaffold/templates.js +4 -2
  44. package/dist/scaffold/templates.js.map +1 -1
  45. package/dist/server/AgentServer.d.ts +1 -0
  46. package/dist/server/AgentServer.d.ts.map +1 -1
  47. package/dist/server/AgentServer.js +22 -0
  48. package/dist/server/AgentServer.js.map +1 -1
  49. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  50. package/dist/server/CapabilityIndex.js +2 -0
  51. package/dist/server/CapabilityIndex.js.map +1 -1
  52. package/dist/server/routes.d.ts +3 -0
  53. package/dist/server/routes.d.ts.map +1 -1
  54. package/dist/server/routes.js +281 -5
  55. package/dist/server/routes.js.map +1 -1
  56. package/package.json +1 -1
  57. package/scripts/stall-matrix-live-check.mjs +309 -0
  58. package/src/data/builtin-manifest.json +64 -64
  59. package/src/scaffold/templates/jobs/instar/stall-matrix-live-check.md +32 -0
  60. package/src/scaffold/templates.ts +4 -2
  61. package/upgrades/1.3.878.md +67 -0
  62. package/upgrades/1.3.879.md +62 -0
  63. package/upgrades/side-effects/correction-derived-hardening.md +188 -0
  64. package/upgrades/side-effects/stall-coverage-matrix-pr-b.md +154 -0
  65. package/upgrades/1.3.877.md +0 -53
@@ -158,6 +158,7 @@ import { withSyncOp } from '../core/InFlightSyncOpMarker.js';
158
158
  import { runAccretionSweep, computeUnbuiltSet, parseDeclaredDeliverables, } from '../core/ScopeAccretionSweep.js';
159
159
  import { resolveCorroboration } from '../core/ScopeAccretionCorroboration.js';
160
160
  import { ScopeAccretionRatifier } from '../core/ScopeAccretionRatifier.js';
161
+ import { runStallGateValidation, readInstallProvenance, degradedAcceptanceHash, canonicalRowHash, canonicalRowSetHash, } from '../core/ApprenticeshipStallGate.js';
161
162
  import { CommitmentTracker } from '../monitoring/CommitmentTracker.js';
162
163
  import { ProcessIntegrity } from '../core/ProcessIntegrity.js';
163
164
  import { decideIngress, commitInboundReply, dedupeKeyFor } from '../messaging/ingressDedup.js';
@@ -1272,6 +1273,40 @@ export function createRoutes(ctx) {
1272
1273
  result,
1273
1274
  });
1274
1275
  if (!result.pass) {
1276
+ // ── Advisory disposition (operator directive 2026-07-18, topic 29723) ──
1277
+ // An advisory-rule citation is a NUDGE, never a terminal block: the
1278
+ // message is returned to the AGENT (not sent, not dropped) with the
1279
+ // pitfall named, and the agent holds the ultimate decision — revise, or
1280
+ // resend unchanged with metadata.toneAdvisoryAck = "<rule>" to
1281
+ // acknowledge; the override is RECORDED (a decision-quality signal,
1282
+ // never authority). A blocking rule can NEVER be overridden this way.
1283
+ if (result.advisory === true) {
1284
+ if (options.toneAdvisoryAck === result.rule) {
1285
+ const overridden = { ...result, advisoryOverridden: true };
1286
+ logToneGateDecision({
1287
+ text,
1288
+ channel,
1289
+ topicId: options.topicId,
1290
+ signals,
1291
+ result: overridden,
1292
+ });
1293
+ return { ok: true };
1294
+ }
1295
+ return {
1296
+ ok: false,
1297
+ status: 422,
1298
+ reason: 'tone-gate-advisory',
1299
+ body: {
1300
+ error: 'tone-gate-advisory',
1301
+ notSent: true,
1302
+ rule: result.rule,
1303
+ issue: result.issue,
1304
+ suggestion: result.suggestion,
1305
+ howToProceed: `ADVISORY — the message was NOT sent, and the decision is yours: revise and re-send, or re-send unchanged with metadata.toneAdvisoryAck set to "${result.rule}" to acknowledge and deliver (the override is recorded).`,
1306
+ latencyMs: result.latencyMs,
1307
+ },
1308
+ };
1309
+ }
1275
1310
  return {
1276
1311
  ok: false,
1277
1312
  status: 422,
@@ -3829,12 +3864,86 @@ export function createRoutes(ctx) {
3829
3864
  recordMetric: (o) => scopeAccretionMetric(o, 'ratification'),
3830
3865
  log: (m) => console.warn(m),
3831
3866
  });
3867
+ const resolveMatrixState = (instanceId, framework) => {
3868
+ try {
3869
+ const logPath = path.join(ctx.config.stateDir, 'logs', 'apprenticeship-decisions.jsonl');
3870
+ const prov = readInstallProvenance(logPath);
3871
+ if (prov.ok && prov.installClass === 'fleet') {
3872
+ return { kind: 'degraded', contentHash: degradedAcceptanceHash(instanceId, framework) };
3873
+ }
3874
+ const out = runStallGateValidation({
3875
+ repoRoot: ctx.config.projectDir,
3876
+ framework,
3877
+ nowIso: new Date().toISOString(),
3878
+ });
3879
+ if (out.fileMissing || !out.result)
3880
+ return null;
3881
+ const rowsById = new Map();
3882
+ for (const r of out.rawRows ?? []) {
3883
+ if (typeof r.class === 'string')
3884
+ rowsById.set(`${framework}:${r.class}`, r);
3885
+ }
3886
+ return { kind: 'source', contentHash: out.result.contentHash, rowsById };
3887
+ }
3888
+ catch {
3889
+ // @silent-fallback-ok — null = "nothing enumerable"; the routes answer an
3890
+ // explicit 409 / hash-mismatch refusal on it, never a silent success.
3891
+ return null;
3892
+ }
3893
+ };
3894
+ const ROW_ID_RE = /^[a-z0-9-]+:[a-z0-9-]+$/;
3895
+ const RULE_RE = /^[a-z0-9-]{1,64}$/;
3896
+ /** The CURRENT content hash for a challenge's SCOPE (Decision 20 binding
3897
+ * granularity) — recomputed at bind time so accept-then-edit voids. */
3898
+ const resolveChallengeCurrentHash = (challenge) => {
3899
+ const state = resolveMatrixState(challenge.instanceId, challenge.framework);
3900
+ if (!state)
3901
+ return null;
3902
+ if (challenge.scope === 'degraded')
3903
+ return state.kind === 'degraded' ? state.contentHash : null;
3904
+ if (state.kind !== 'source')
3905
+ return null;
3906
+ if (challenge.scope === 'whole-set')
3907
+ return state.contentHash;
3908
+ if (challenge.scope === 'rows') {
3909
+ const entries = [];
3910
+ for (const id of challenge.rowIds) {
3911
+ const row = state.rowsById.get(id);
3912
+ if (!row)
3913
+ return null;
3914
+ entries.push({ rowId: id, row });
3915
+ }
3916
+ return canonicalRowSetHash(entries);
3917
+ }
3918
+ if (challenge.scope === 'override') {
3919
+ const row = challenge.rowIds.length === 1 ? state.rowsById.get(challenge.rowIds[0]) : undefined;
3920
+ return row ? canonicalRowHash(row) : null;
3921
+ }
3922
+ return null;
3923
+ };
3924
+ // Conversational bind (mirrors the ratifier's reply-anchor mechanic): a
3925
+ // verified-operator reply onto a recorded enumeration message binds the
3926
+ // challenge. Signal-only — fire-and-forget on both ingress paths.
3927
+ const observeMatrixAcceptanceInbound = (evt) => {
3928
+ const store = ctx.apprenticeshipMatrixAcceptance;
3929
+ if (!store)
3930
+ return;
3931
+ void store.observeInbound(evt, {
3932
+ getOperatorUid: (topicId) => ctx.topicOperatorStore?.getOperator(topicId)?.uid ?? null,
3933
+ resolveCurrentContentHash: (challenge) => resolveChallengeCurrentHash(challenge),
3934
+ ack: async (topicId, text) => {
3935
+ if (ctx.telegram)
3936
+ await ctx.telegram.sendToTopic(topicId, text);
3937
+ },
3938
+ });
3939
+ };
3832
3940
  if (ctx.telegram) {
3833
3941
  // Direct-poll ingress (the server long-polls Telegram itself, R45). The
3834
- // lifeline-forward ingress fires the same observer inside
3942
+ // lifeline-forward ingress fires the same observers inside
3835
3943
  // POST /internal/telegram-forward below.
3836
3944
  ctx.telegram.onScopeAccretionInbound = (evt) => {
3837
3945
  void scopeAccretionRatifier.observeInbound(evt);
3946
+ observeMatrixAcceptanceInbound(evt);
3838
3947
  };
3839
3948
  }
3840
3949
  // ── POST /autonomous/register (R30/R43/R49) — the server-side start snapshot ──
@@ -11360,6 +11469,12 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
11360
11469
  const senderClass = coerceSenderClass(metadata?.senderClass);
11361
11470
  const advisoryAck = metadata?.advisoryAck === true;
11362
11471
  const advisoryCodes = coerceAdvisoryCodes(metadata?.advisoryCodes);
11472
+ // Advisory-rule override (operator directive 2026-07-18): the FULL rule id
11473
+ // the sender explicitly acknowledges. Length-clamped; validated against the
11474
+ // cited rule at the seam (a mismatch or a blocking rule is never overridden).
11475
+ const toneAdvisoryAck = typeof metadata?.toneAdvisoryAck === 'string'
11476
+ ? metadata.toneAdvisoryAck.slice(0, 64)
11477
+ : undefined;
11363
11478
  const metadataJobSlug = typeof metadata?.jobSlug === 'string' ? metadata.jobSlug.slice(0, 128) : '';
11364
11479
  // ── Observability breadcrumbs (§2.1 — visibility on the named dodge
11365
11480
  // classes; sovereignty over the send is accepted, nothing is gated). ──
@@ -11435,6 +11550,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
11435
11550
  allowDuplicate,
11436
11551
  allowLocalhostLink,
11437
11552
  messageKind,
11553
+ toneAdvisoryAck,
11438
11554
  })))
11439
11555
  return;
11440
11556
  // In-flight reservation (2026-07-03): the isDuplicate pre-check above and the
@@ -12296,6 +12412,9 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
12296
12412
  if (await checkOutboundMessage(text, 'slack', res, {
12297
12413
  allowDebugText: metadata?.allowDebugText === true,
12298
12414
  allowDuplicate: metadata?.allowDuplicate === true,
12415
+ toneAdvisoryAck: typeof metadata?.toneAdvisoryAck === 'string'
12416
+ ? metadata.toneAdvisoryAck.slice(0, 64)
12417
+ : undefined,
12299
12418
  // Kind threading mirrors /telegram/reply — the jargon/filePath signal
12300
12419
  // computation is single-sourced inside evaluateOutbound, so every
12301
12420
  // channel gets it uniformly (spec outbound-jargon-filepath-gap §2.2).
@@ -17614,6 +17733,13 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
17614
17733
  replyToMessageId: fwdReplyTo,
17615
17734
  at: Date.now(),
17616
17735
  });
17736
+ observeMatrixAcceptanceInbound({
17737
+ topicId: Number(topicId),
17738
+ text: String(text),
17739
+ senderUid: String(fromUserId ?? ''),
17740
+ messageId: Number(messageId) || 0,
17741
+ replyToMessageId: fwdReplyTo,
17742
+ });
17617
17743
  }
17618
17744
  // ── Exactly-once ingress gate (spec §8 G3a) ──────────────────────
17619
17745
  // Default-DARK: only active when multiMachine.exactlyOnceIngress wired the
@@ -20250,7 +20376,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
20250
20376
  res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
20251
20377
  }
20252
20378
  });
20253
- router.post('/apprenticeship/instances/:id/transition', (req, res) => {
20379
+ router.post('/apprenticeship/instances/:id/transition', async (req, res) => {
20254
20380
  if (!ctx.apprenticeshipProgram) {
20255
20381
  res.status(503).json({ error: 'apprenticeship program disabled' });
20256
20382
  return;
@@ -20260,7 +20386,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
20260
20386
  res.status(400).json({ error: 'to must be one of pending | active | complete | blocked | abandoned' });
20261
20387
  return;
20262
20388
  }
20263
- const result = ctx.apprenticeshipProgram.transition(req.params.id, to);
20389
+ const result = await ctx.apprenticeshipProgram.transition(req.params.id, to);
20264
20390
  if (!result.ok) {
20265
20391
  // 404 for a missing instance; 409 for a refused/illegal transition.
20266
20392
  const code = result.reason.includes('not found') ? 404 : 409;
@@ -20294,7 +20420,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
20294
20420
  }
20295
20421
  res.json(ctx.apprenticeshipProgram.evaluateStartGate(inst));
20296
20422
  });
20297
- router.post('/apprenticeship/instances/:id/can-complete', (req, res) => {
20423
+ router.post('/apprenticeship/instances/:id/can-complete', async (req, res) => {
20298
20424
  if (!ctx.apprenticeshipProgram) {
20299
20425
  res.status(503).json({ error: 'apprenticeship program disabled' });
20300
20426
  return;
@@ -20304,7 +20430,157 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
20304
20430
  res.status(404).json({ error: 'not found' });
20305
20431
  return;
20306
20432
  }
20307
- res.json(ctx.apprenticeshipProgram.evaluateCompletionGate(inst));
20433
+ res.json(await ctx.apprenticeshipProgram.evaluateCompletionGate(inst));
20434
+ });
20435
+ // ── Stall-coverage matrix acceptance (framework-stall-coverage-matrix §2.2) ──
20436
+ //
20437
+ // POST /apprenticeship/instances/:id/matrix-acceptance/enumerate — Bearer;
20438
+ // the SERVER renders + records the exact enumerated set (a challenge).
20439
+ // Optional { topicId } posts the enumeration to the topic so the
20440
+ // verified operator can bind it with a reply-anchored yes/approve (the
20441
+ // ScopeAccretionRatifier mechanic).
20442
+ // POST /apprenticeship/instances/:id/matrix-acceptance — dashboard-PIN
20443
+ // bind: { pin, challengeId }. Requester ≠ acceptor is structural — the
20444
+ // Bearer token alone can NEVER record an acceptance (Decision 14).
20445
+ router.post('/apprenticeship/instances/:id/matrix-acceptance/enumerate', async (req, res) => {
20446
+ if (!ctx.apprenticeshipProgram || !ctx.apprenticeshipMatrixAcceptance) {
20447
+ res.status(503).json({ error: 'apprenticeship program disabled' });
20448
+ return;
20449
+ }
20450
+ const inst = ctx.apprenticeshipProgram.get(req.params.id);
20451
+ if (!inst) {
20452
+ res.status(404).json({ error: 'not found' });
20453
+ return;
20454
+ }
20455
+ const state = resolveMatrixState(inst.id, inst.framework);
20456
+ if (!state) {
20457
+ res.status(409).json({ error: `no stall-coverage matrix content to enumerate for framework '${inst.framework}'` });
20458
+ return;
20459
+ }
20460
+ // Scope-aware mint (Decision 20): whole-set (default) / rows / override.
20461
+ // Rows + override are the production path for `acceptanceRef:` clears and
20462
+ // the §3.4 per-instance relief — hashed via the SAME canonical-row
20463
+ // serialization the gate re-derives at verify time.
20464
+ const requestedScope = typeof req.body?.scope === 'string' ? req.body.scope : 'whole-set';
20465
+ let enumerated;
20466
+ if (state.kind === 'degraded') {
20467
+ if (requestedScope !== 'whole-set' && requestedScope !== 'degraded') {
20468
+ res.status(409).json({ error: 'row/override-scoped acceptance is not available on a no-source install (degraded verdict only)' });
20469
+ return;
20470
+ }
20471
+ enumerated = { scope: 'degraded', contentHash: state.contentHash, rowIds: [] };
20472
+ }
20473
+ else if (requestedScope === 'whole-set') {
20474
+ enumerated = { scope: 'whole-set', contentHash: state.contentHash, rowIds: [...state.rowsById.keys()] };
20475
+ }
20476
+ else if (requestedScope === 'rows') {
20477
+ const rowIds = Array.isArray(req.body?.rowIds)
20478
+ ? req.body.rowIds.filter((x) => typeof x === 'string')
20479
+ : [];
20480
+ if (rowIds.length === 0 || rowIds.length > 64 || !rowIds.every((id) => ROW_ID_RE.test(id))) {
20481
+ res.status(400).json({ error: '"rowIds" (non-empty array of "<framework>:<classId>" strings) required for scope "rows"' });
20482
+ return;
20483
+ }
20484
+ const entries = [];
20485
+ for (const id of rowIds) {
20486
+ const row = state.rowsById.get(id);
20487
+ if (!row) {
20488
+ res.status(409).json({ error: `rowId '${id}' does not resolve to a current matrix row` });
20489
+ return;
20490
+ }
20491
+ entries.push({ rowId: id, row });
20492
+ }
20493
+ enumerated = { scope: 'rows', contentHash: canonicalRowSetHash(entries), rowIds: [...rowIds].sort() };
20494
+ }
20495
+ else if (requestedScope === 'override') {
20496
+ const rule = typeof req.body?.rule === 'string' ? req.body.rule : '';
20497
+ const classId = typeof req.body?.classId === 'string' ? req.body.classId : '';
20498
+ if (!RULE_RE.test(rule) || !/^[a-z0-9-]+$/.test(classId)) {
20499
+ res.status(400).json({ error: '"rule" (kebab rule name) and "classId" required for scope "override"' });
20500
+ return;
20501
+ }
20502
+ const rowId = `${inst.framework}:${classId}`;
20503
+ const row = state.rowsById.get(rowId);
20504
+ if (!row) {
20505
+ res.status(409).json({ error: `classId '${classId}' does not resolve to a current matrix row` });
20506
+ return;
20507
+ }
20508
+ enumerated = { scope: 'override', contentHash: canonicalRowHash(row), rowIds: [rowId], rule };
20509
+ }
20510
+ else {
20511
+ res.status(400).json({ error: '"scope" must be one of whole-set | rows | override' });
20512
+ return;
20513
+ }
20514
+ const challenge = ctx.apprenticeshipMatrixAcceptance.mintChallenge({
20515
+ instanceId: inst.id,
20516
+ framework: inst.framework,
20517
+ scope: enumerated.scope,
20518
+ contentHash: enumerated.contentHash,
20519
+ rowIds: enumerated.rowIds,
20520
+ ...(enumerated.rule ? { rule: enumerated.rule } : {}),
20521
+ });
20522
+ const renderedText = ctx.apprenticeshipMatrixAcceptance.renderEnumeration(challenge);
20523
+ // Conversational arm: post the server-authored enumeration to the topic so
20524
+ // a reply-anchored verified-operator confirmation can bind it.
20525
+ let posted = null;
20526
+ const topicId = typeof req.body?.topicId === 'number' ? req.body.topicId : null;
20527
+ if (topicId !== null && ctx.telegram) {
20528
+ try {
20529
+ const sent = await ctx.telegram.sendToTopic(topicId, renderedText);
20530
+ posted = { topicId, messageId: sent.messageId };
20531
+ ctx.apprenticeshipMatrixAcceptance.attachMessage(challenge.challengeId, posted);
20532
+ }
20533
+ catch {
20534
+ // @silent-fallback-ok — a failed Telegram post only loses the
20535
+ // conversational anchor; the challenge is recorded and the PIN path
20536
+ // remains fully available (the response reports posted: null).
20537
+ posted = null;
20538
+ }
20539
+ }
20540
+ res.json({
20541
+ challengeId: challenge.challengeId,
20542
+ scope: challenge.scope,
20543
+ contentHash: challenge.contentHash,
20544
+ rowIds: challenge.rowIds,
20545
+ ...(challenge.rule ? { rule: challenge.rule } : {}),
20546
+ renderedText,
20547
+ posted,
20548
+ });
20549
+ });
20550
+ router.post('/apprenticeship/instances/:id/matrix-acceptance', (req, res) => {
20551
+ if (!ctx.apprenticeshipProgram || !ctx.apprenticeshipMatrixAcceptance) {
20552
+ res.status(503).json({ error: 'apprenticeship program disabled' });
20553
+ return;
20554
+ }
20555
+ if (!checkMandatePin(req, res))
20556
+ return; // PIN-gated: a Bearer token is structurally insufficient
20557
+ const inst = ctx.apprenticeshipProgram.get(req.params.id);
20558
+ if (!inst) {
20559
+ res.status(404).json({ error: 'not found' });
20560
+ return;
20561
+ }
20562
+ const challengeId = typeof req.body?.challengeId === 'string' ? req.body.challengeId : '';
20563
+ if (!challengeId) {
20564
+ res.status(400).json({ error: '"challengeId" (string) required' });
20565
+ return;
20566
+ }
20567
+ const challenge = ctx.apprenticeshipMatrixAcceptance.getChallenge(challengeId);
20568
+ if (!challenge || challenge.instanceId !== inst.id) {
20569
+ res.status(404).json({ error: 'unknown challenge for this instance' });
20570
+ return;
20571
+ }
20572
+ // Re-resolve the CURRENT content for the challenge's SCOPE at bind time —
20573
+ // accept-then-edit voids the challenge (§2.2 / Decision 20).
20574
+ const bound = ctx.apprenticeshipMatrixAcceptance.bind({
20575
+ challengeId,
20576
+ principal: { kind: 'operator-pin', id: 'dashboard-pin' },
20577
+ currentContentHash: resolveChallengeCurrentHash(challenge),
20578
+ });
20579
+ if (!bound.ok) {
20580
+ res.status(409).json({ ok: false, reason: bound.reason });
20581
+ return;
20582
+ }
20583
+ res.json({ ok: true, reason: bound.reason, challengeRef: challengeId });
20308
20584
  });
20309
20585
  // ORG-INTENT.md tradeoff resolution (Phase 3 of the ORG-INTENT runtime
20310
20586
  // project). Given two contending values, consults the organization's