instar 1.3.489 → 1.3.491

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 (175) hide show
  1. package/dashboard/index.html +30 -0
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +1124 -28
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  6. package/dist/config/ConfigDefaults.js +23 -0
  7. package/dist/config/ConfigDefaults.js.map +1 -1
  8. package/dist/core/CodexResumeMap.d.ts +95 -0
  9. package/dist/core/CodexResumeMap.d.ts.map +1 -0
  10. package/dist/core/CodexResumeMap.js +283 -0
  11. package/dist/core/CodexResumeMap.js.map +1 -0
  12. package/dist/core/GuardPostureStore.d.ts +38 -0
  13. package/dist/core/GuardPostureStore.d.ts.map +1 -0
  14. package/dist/core/GuardPostureStore.js +87 -0
  15. package/dist/core/GuardPostureStore.js.map +1 -0
  16. package/dist/core/MachinePoolRegistry.d.ts +16 -0
  17. package/dist/core/MachinePoolRegistry.d.ts.map +1 -1
  18. package/dist/core/MachinePoolRegistry.js +24 -1
  19. package/dist/core/MachinePoolRegistry.js.map +1 -1
  20. package/dist/core/MeshRpc.d.ts +3 -0
  21. package/dist/core/MeshRpc.d.ts.map +1 -1
  22. package/dist/core/MeshRpc.js +5 -0
  23. package/dist/core/MeshRpc.js.map +1 -1
  24. package/dist/core/ModelSwapService.d.ts +26 -11
  25. package/dist/core/ModelSwapService.d.ts.map +1 -1
  26. package/dist/core/ModelSwapService.js +59 -21
  27. package/dist/core/ModelSwapService.js.map +1 -1
  28. package/dist/core/PeerPresencePuller.d.ts +9 -0
  29. package/dist/core/PeerPresencePuller.d.ts.map +1 -1
  30. package/dist/core/PeerPresencePuller.js +1 -1
  31. package/dist/core/PeerPresencePuller.js.map +1 -1
  32. package/dist/core/PostUpdateMigrator.d.ts +16 -0
  33. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  34. package/dist/core/PostUpdateMigrator.js +92 -4
  35. package/dist/core/PostUpdateMigrator.js.map +1 -1
  36. package/dist/core/SessionManager.d.ts +4 -0
  37. package/dist/core/SessionManager.d.ts.map +1 -1
  38. package/dist/core/SessionManager.js +3 -0
  39. package/dist/core/SessionManager.js.map +1 -1
  40. package/dist/core/SessionRefresh.d.ts +36 -7
  41. package/dist/core/SessionRefresh.d.ts.map +1 -1
  42. package/dist/core/SessionRefresh.js +90 -29
  43. package/dist/core/SessionRefresh.js.map +1 -1
  44. package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
  45. package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
  46. package/dist/core/TopicProfileOrchestrator.js +1404 -0
  47. package/dist/core/TopicProfileOrchestrator.js.map +1 -0
  48. package/dist/core/TopicProfileResolver.d.ts +104 -0
  49. package/dist/core/TopicProfileResolver.d.ts.map +1 -0
  50. package/dist/core/TopicProfileResolver.js +231 -0
  51. package/dist/core/TopicProfileResolver.js.map +1 -0
  52. package/dist/core/TopicProfileStore.d.ts +308 -0
  53. package/dist/core/TopicProfileStore.d.ts.map +1 -0
  54. package/dist/core/TopicProfileStore.js +781 -0
  55. package/dist/core/TopicProfileStore.js.map +1 -0
  56. package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
  57. package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
  58. package/dist/core/TopicProfileTransferCarrier.js +533 -0
  59. package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
  60. package/dist/core/TopicResumeMap.d.ts +67 -1
  61. package/dist/core/TopicResumeMap.d.ts.map +1 -1
  62. package/dist/core/TopicResumeMap.js +114 -1
  63. package/dist/core/TopicResumeMap.js.map +1 -1
  64. package/dist/core/classifyProfileChange.d.ts +83 -0
  65. package/dist/core/classifyProfileChange.d.ts.map +1 -0
  66. package/dist/core/classifyProfileChange.js +274 -0
  67. package/dist/core/classifyProfileChange.js.map +1 -0
  68. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  69. package/dist/core/devGatedFeatures.js +6 -0
  70. package/dist/core/devGatedFeatures.js.map +1 -1
  71. package/dist/core/frameworkSessionLaunch.d.ts +26 -0
  72. package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
  73. package/dist/core/frameworkSessionLaunch.js +58 -0
  74. package/dist/core/frameworkSessionLaunch.js.map +1 -1
  75. package/dist/core/slackRefreshBinding.d.ts +83 -0
  76. package/dist/core/slackRefreshBinding.d.ts.map +1 -0
  77. package/dist/core/slackRefreshBinding.js +54 -0
  78. package/dist/core/slackRefreshBinding.js.map +1 -0
  79. package/dist/core/topicProfileIngress.d.ts +128 -0
  80. package/dist/core/topicProfileIngress.d.ts.map +1 -0
  81. package/dist/core/topicProfileIngress.js +249 -0
  82. package/dist/core/topicProfileIngress.js.map +1 -0
  83. package/dist/core/topicProfileValidation.d.ts +109 -0
  84. package/dist/core/topicProfileValidation.d.ts.map +1 -0
  85. package/dist/core/topicProfileValidation.js +247 -0
  86. package/dist/core/topicProfileValidation.js.map +1 -0
  87. package/dist/core/topicProfileWriteSurface.d.ts +222 -0
  88. package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
  89. package/dist/core/topicProfileWriteSurface.js +631 -0
  90. package/dist/core/topicProfileWriteSurface.js.map +1 -0
  91. package/dist/core/types.d.ts +66 -0
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/types.js.map +1 -1
  94. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  95. package/dist/lifeline/TelegramLifeline.js +6 -0
  96. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  97. package/dist/messaging/TelegramAdapter.d.ts +10 -1
  98. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  99. package/dist/messaging/TelegramAdapter.js +41 -1
  100. package/dist/messaging/TelegramAdapter.js.map +1 -1
  101. package/dist/monitoring/ActiveWorkSilenceSentinel.d.ts +8 -0
  102. package/dist/monitoring/ActiveWorkSilenceSentinel.d.ts.map +1 -1
  103. package/dist/monitoring/ActiveWorkSilenceSentinel.js +8 -0
  104. package/dist/monitoring/ActiveWorkSilenceSentinel.js.map +1 -1
  105. package/dist/monitoring/ContextWedgeSentinel.d.ts +8 -0
  106. package/dist/monitoring/ContextWedgeSentinel.d.ts.map +1 -1
  107. package/dist/monitoring/ContextWedgeSentinel.js +8 -0
  108. package/dist/monitoring/ContextWedgeSentinel.js.map +1 -1
  109. package/dist/monitoring/GuardPostureTripwire.d.ts +3 -27
  110. package/dist/monitoring/GuardPostureTripwire.d.ts.map +1 -1
  111. package/dist/monitoring/GuardPostureTripwire.js +8 -76
  112. package/dist/monitoring/GuardPostureTripwire.js.map +1 -1
  113. package/dist/monitoring/GuardRegistry.d.ts +48 -0
  114. package/dist/monitoring/GuardRegistry.d.ts.map +1 -0
  115. package/dist/monitoring/GuardRegistry.js +49 -0
  116. package/dist/monitoring/GuardRegistry.js.map +1 -0
  117. package/dist/monitoring/SessionReaper.d.ts +7 -0
  118. package/dist/monitoring/SessionReaper.d.ts.map +1 -1
  119. package/dist/monitoring/SessionReaper.js +9 -0
  120. package/dist/monitoring/SessionReaper.js.map +1 -1
  121. package/dist/monitoring/SessionWatchdog.d.ts +8 -0
  122. package/dist/monitoring/SessionWatchdog.d.ts.map +1 -1
  123. package/dist/monitoring/SessionWatchdog.js +8 -0
  124. package/dist/monitoring/SessionWatchdog.js.map +1 -1
  125. package/dist/monitoring/SocketDisconnectSentinel.d.ts +8 -0
  126. package/dist/monitoring/SocketDisconnectSentinel.d.ts.map +1 -1
  127. package/dist/monitoring/SocketDisconnectSentinel.js +8 -0
  128. package/dist/monitoring/SocketDisconnectSentinel.js.map +1 -1
  129. package/dist/monitoring/guardManifest.d.ts +67 -0
  130. package/dist/monitoring/guardManifest.d.ts.map +1 -0
  131. package/dist/monitoring/guardManifest.js +536 -0
  132. package/dist/monitoring/guardManifest.js.map +1 -0
  133. package/dist/monitoring/guardPosture.d.ts +77 -0
  134. package/dist/monitoring/guardPosture.d.ts.map +1 -0
  135. package/dist/monitoring/guardPosture.js +198 -0
  136. package/dist/monitoring/guardPosture.js.map +1 -0
  137. package/dist/monitoring/guardPostureView.d.ts +100 -0
  138. package/dist/monitoring/guardPostureView.d.ts.map +1 -0
  139. package/dist/monitoring/guardPostureView.js +294 -0
  140. package/dist/monitoring/guardPostureView.js.map +1 -0
  141. package/dist/monitoring/probes/GuardPostureProbe.d.ts +82 -0
  142. package/dist/monitoring/probes/GuardPostureProbe.d.ts.map +1 -0
  143. package/dist/monitoring/probes/GuardPostureProbe.js +384 -0
  144. package/dist/monitoring/probes/GuardPostureProbe.js.map +1 -0
  145. package/dist/scaffold/templates.d.ts.map +1 -1
  146. package/dist/scaffold/templates.js +18 -0
  147. package/dist/scaffold/templates.js.map +1 -1
  148. package/dist/scheduler/JobScheduler.d.ts +10 -0
  149. package/dist/scheduler/JobScheduler.d.ts.map +1 -1
  150. package/dist/scheduler/JobScheduler.js +12 -0
  151. package/dist/scheduler/JobScheduler.js.map +1 -1
  152. package/dist/server/AgentServer.d.ts +40 -0
  153. package/dist/server/AgentServer.d.ts.map +1 -1
  154. package/dist/server/AgentServer.js +54 -3
  155. package/dist/server/AgentServer.js.map +1 -1
  156. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  157. package/dist/server/CapabilityIndex.js +12 -0
  158. package/dist/server/CapabilityIndex.js.map +1 -1
  159. package/dist/server/peerUrlGuard.d.ts +35 -0
  160. package/dist/server/peerUrlGuard.d.ts.map +1 -0
  161. package/dist/server/peerUrlGuard.js +93 -0
  162. package/dist/server/peerUrlGuard.js.map +1 -0
  163. package/dist/server/routes.d.ts +31 -0
  164. package/dist/server/routes.d.ts.map +1 -1
  165. package/dist/server/routes.js +407 -1
  166. package/dist/server/routes.js.map +1 -1
  167. package/package.json +3 -2
  168. package/scripts/lint-guard-manifest.js +264 -0
  169. package/src/data/builtin-manifest.json +67 -67
  170. package/src/data/state-coherence-registry.json +54 -18
  171. package/src/scaffold/templates.ts +18 -0
  172. package/upgrades/1.3.490.md +34 -0
  173. package/upgrades/1.3.491.md +37 -0
  174. package/upgrades/side-effects/guard-posture-endpoint.md +101 -0
  175. package/upgrades/side-effects/topic-profile.md +142 -0
@@ -32,6 +32,10 @@ import { CoherenceJournalReader, InvalidCursorError } from '../core/CoherenceJou
32
32
  import { creditUsherOnOutbound } from '../core/UsherActedCorrelator.js';
33
33
  import { validateWriteToken, canPerformOperation } from '../core/StateWriteAuthority.js';
34
34
  import { DegradationReporter } from '../monitoring/DegradationReporter.js';
35
+ import { readGuardPostureBootSnapshot, resolveGuardConfigSnapshot, } from '../monitoring/guardPosture.js';
36
+ import { buildGuardInventory } from '../monitoring/guardPostureView.js';
37
+ import { GuardRegistry } from '../monitoring/GuardRegistry.js';
38
+ import { isPeerUrlAllowedForCredentials } from './peerUrlGuard.js';
35
39
  import { FailureLedger } from '../monitoring/FailureLedger.js';
36
40
  import { FailureAttributionEngine } from '../monitoring/FailureAttributionEngine.js';
37
41
  import { FailureAnalyzer } from '../monitoring/FailureAnalyzer.js';
@@ -4270,6 +4274,138 @@ export function createRoutes(ctx) {
4270
4274
  const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 1000) : 200;
4271
4275
  res.json({ entries: ctx.reapLog.read(limit) });
4272
4276
  });
4277
+ // ── Guard Posture (GUARD-POSTURE-ENDPOINT-SPEC) ─────────────────────
4278
+ // Read-only, Bearer-auth (router-level middleware — NEVER added to any auth
4279
+ // exemption list: posture is an attack-timing oracle, spec §3). Reports
4280
+ // every shipped guard's HONEST effective state: config-on is not working
4281
+ // (the Mini's reaper lesson). Local inventory is fully synchronous (one
4282
+ // disk-read config snapshot + sync in-memory runtime getters, <100ms);
4283
+ // ?scope=pool fans out to peers with classified per-peer failures, never a
4284
+ // 500. Deliberately NO `guards.enabled` config gate — an off-switch on the
4285
+ // guard-visibility surface would itself be an invisible disabled guard
4286
+ // (always-on read-only observability; reap-log/TokenLedger precedent).
4287
+ const guardsPoolLimiter = rateLimiter(60_000, 6);
4288
+ router.get('/guards', async (req, res) => {
4289
+ const stateDir = ctx.config.stateDir;
4290
+ const projectDir = path.dirname(stateDir);
4291
+ const snapshot = resolveGuardConfigSnapshot(projectDir);
4292
+ if (snapshot.readError) {
4293
+ // Config-read failure is a top-level error — never a truthful-looking
4294
+ // empty inventory (spec §2.2).
4295
+ res.status(500).json({ error: `guard inventory unavailable: config read failed (${snapshot.readError})` });
4296
+ return;
4297
+ }
4298
+ const inventory = buildGuardInventory({
4299
+ snapshot,
4300
+ bootSnapshot: readGuardPostureBootSnapshot(stateDir),
4301
+ registry: ctx.guardRegistry ?? new GuardRegistry(),
4302
+ });
4303
+ const selfMachineId = ctx.meshSelfId ?? null;
4304
+ const body = {
4305
+ machineId: selfMachineId,
4306
+ nickname: selfMachineId
4307
+ ? (ctx.machinePoolRegistry?.getCapacity(selfMachineId)?.nickname ?? null)
4308
+ : null,
4309
+ version: ProcessIntegrity.getInstance()?.runningVersion || ctx.config.version || '0.0.0',
4310
+ generatedAt: new Date().toISOString(),
4311
+ guards: inventory.guards,
4312
+ summary: inventory.summary,
4313
+ };
4314
+ if (req.query.scope !== 'pool') {
4315
+ res.json(body);
4316
+ return;
4317
+ }
4318
+ // scope=pool — every registered machine accounted for: a posture row or a
4319
+ // NAMED, classified failure row; silent omission re-creates the blind spot
4320
+ // at the pool level (spec §2.3). Non-recursive (peers get plain /guards),
4321
+ // rate-limited (anti-amplification, spec §3(d)).
4322
+ guardsPoolLimiter(req, res, async () => {
4323
+ const known = (ctx.listPoolMachines?.() ?? []).filter((m) => m.machineId !== selfMachineId);
4324
+ if (known.length === 0) {
4325
+ res.json({ ...body, pool: { enabled: false, peersQueried: 0, knownMachines: 0, machines: [], failed: [] } });
4326
+ return;
4327
+ }
4328
+ const extraAllowlist = ctx.config.multiMachine
4329
+ ?.peerUrlAllowlist;
4330
+ const machines = [];
4331
+ const failed = [];
4332
+ let peersQueried = 0;
4333
+ await Promise.all(known.map(async (m) => {
4334
+ const capacity = ctx.machinePoolRegistry?.getCapacity(m.machineId) ?? null;
4335
+ const peerVersion = capacity?.hardware?.instarVersion;
4336
+ if (!m.lastKnownUrl) {
4337
+ failed.push({ machineId: m.machineId, reason: 'no-known-url' });
4338
+ return;
4339
+ }
4340
+ if (capacity && capacity.online === false) {
4341
+ // The heartbeat piggyback (durable last-known posture on the pool
4342
+ // view) covers dark peers — a doomed fetch buys nothing but timeout.
4343
+ failed.push({ machineId: m.machineId, reason: 'offline' });
4344
+ return;
4345
+ }
4346
+ const verdict = isPeerUrlAllowedForCredentials(m.lastKnownUrl, extraAllowlist);
4347
+ if (!verdict.ok) {
4348
+ // The Bearer token was NEVER attached (spec §3(c)) — visible, named.
4349
+ failed.push({ machineId: m.machineId, reason: 'url-rejected' });
4350
+ return;
4351
+ }
4352
+ peersQueried++;
4353
+ try {
4354
+ const r = await fetch(`${m.lastKnownUrl}/guards`, {
4355
+ headers: { Authorization: `Bearer ${ctx.config.authToken}` },
4356
+ signal: AbortSignal.timeout(5000),
4357
+ });
4358
+ if (r.status === 404) {
4359
+ // Pre-/guards peer version: "needs update to report", not a
4360
+ // phantom outage (rollout-skew honesty, spec §2.3).
4361
+ failed.push({ machineId: m.machineId, reason: 'route-missing', ...(peerVersion ? { version: peerVersion } : {}) });
4362
+ return;
4363
+ }
4364
+ if (r.status === 401 || r.status === 403) {
4365
+ failed.push({ machineId: m.machineId, reason: 'unauthorized' });
4366
+ return;
4367
+ }
4368
+ if (!r.ok) {
4369
+ failed.push({ machineId: m.machineId, reason: 'error' });
4370
+ return;
4371
+ }
4372
+ const peerBody = (await r.json());
4373
+ machines.push({
4374
+ // Merge identity: the row is keyed on the REGISTRY's machine
4375
+ // identity; the body's self-reported machineId is data and a
4376
+ // mismatch is FLAGGED, never allowed to shadow another row.
4377
+ machineId: m.machineId,
4378
+ nickname: capacity?.nickname ?? m.nickname,
4379
+ ...(typeof peerBody.machineId === 'string' && peerBody.machineId !== m.machineId
4380
+ ? { identityMismatch: true, claimedMachineId: peerBody.machineId }
4381
+ : {}),
4382
+ version: peerBody.version,
4383
+ generatedAt: peerBody.generatedAt,
4384
+ guards: peerBody.guards,
4385
+ summary: peerBody.summary,
4386
+ });
4387
+ }
4388
+ catch (err) {
4389
+ // Normalized enum only — raw err.message leaks URL/TLS internals.
4390
+ const name = err instanceof Error ? err.name : '';
4391
+ failed.push({
4392
+ machineId: m.machineId,
4393
+ reason: name === 'TimeoutError' || name === 'AbortError' ? 'timeout' : 'unreachable',
4394
+ });
4395
+ }
4396
+ }));
4397
+ res.json({
4398
+ ...body,
4399
+ pool: {
4400
+ enabled: !!ctx.machinePoolRegistry,
4401
+ knownMachines: known.length,
4402
+ peersQueried,
4403
+ machines,
4404
+ failed,
4405
+ },
4406
+ });
4407
+ });
4408
+ });
4273
4409
  // Coherence Journal merged read API (COHERENCE-JOURNAL-SPEC §3.5). The
4274
4410
  // pull-surface answer to "which machine was topic N on / where are its
4275
4411
  // artifacts?" — a bounded, honest-about-trust merged view over this machine's
@@ -4548,7 +4684,19 @@ export function createRoutes(ctx) {
4548
4684
  return;
4549
4685
  }
4550
4686
  try {
4551
- const result = await ctx.modelTierSwap.swap(req.params.name, tier);
4687
+ // TOPIC-PROFILE-SPEC §9 the in-flight swap is a live-session mutation:
4688
+ // serialize it through the topic's single-writer lock (orchestrator
4689
+ // runExclusive) so it can never interleave with a profile-triggered
4690
+ // kill/respawn on the same topic. The escalation reconciler hook drives
4691
+ // this same route, so one wrap covers both initiators. No orchestrator /
4692
+ // no topic binding ⇒ today's unserialized behavior (single-machine boot
4693
+ // window / non-topic sessions).
4694
+ const performSwap = () => ctx.modelTierSwap.swap(req.params.name, tier);
4695
+ const orch = ctx.topicProfile?.orchestrator ?? null;
4696
+ const swapTopicId = ctx.telegram?.getTopicForSession?.(req.params.name) ?? null;
4697
+ const result = orch && swapTopicId !== null
4698
+ ? await orch.runExclusive(swapTopicId, performSwap)
4699
+ : await performSwap();
4552
4700
  const httpStatus = result.status === 'refused'
4553
4701
  ? result.reason === 'unknown-session'
4554
4702
  ? 404
@@ -4566,6 +4714,236 @@ export function createRoutes(ctx) {
4566
4714
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
4567
4715
  }
4568
4716
  });
4717
+ // ── Topic Profile (TOPIC-PROFILE-SPEC §10.1 / §12) ────────────────────────
4718
+ // GET: the read surface (Bearer — profile entries carry the operator's
4719
+ // platform uid in updatedBy). POST: the TOKEN-TRUST write surface —
4720
+ // authenticated by Bearer + X-Instar-Request like sibling mutating routes,
4721
+ // recorded as `updatedBy:'api-token'`, NEVER operator-attributed; a
4722
+ // body-supplied updatedBy is ignored by construction. The §12.5 key clamp:
4723
+ // numeric Telegram topic id, or `slack:<channel>[:<thread>]`.
4724
+ const TOPIC_PROFILE_KEY_RE = /^(?:\d{1,16}|slack:[A-Za-z0-9._-]{1,64}(?::[0-9.]{1,32})?)$/;
4725
+ const topicProfileRefusalStatus = (reason) => {
4726
+ switch (reason) {
4727
+ case 'validation':
4728
+ case 'empty-patch':
4729
+ return 400;
4730
+ case 'no-bound-operator':
4731
+ case 'not-bound-operator':
4732
+ return 403;
4733
+ case 'disabled':
4734
+ case 'local-model-binding-active':
4735
+ case 'lock-timeout':
4736
+ case 'nothing-to-undo':
4737
+ case 'nothing-parked':
4738
+ case 'cooldown-confirm-required':
4739
+ return 409;
4740
+ case 'flush-failed':
4741
+ return 500;
4742
+ default:
4743
+ return 400;
4744
+ }
4745
+ };
4746
+ router.get('/topic-profile/:topicId', (req, res) => {
4747
+ if (!TOPIC_PROFILE_KEY_RE.test(req.params.topicId)) {
4748
+ res.status(400).json({ error: 'Invalid topic key — numeric topic id or slack:<channel>[:<thread>]' });
4749
+ return;
4750
+ }
4751
+ if (!ctx.topicProfile) {
4752
+ res.status(503).json({ error: 'Topic profiles not wired on this server' });
4753
+ return;
4754
+ }
4755
+ const key = req.params.topicId;
4756
+ const entry = ctx.topicProfile.store.get(key);
4757
+ const resolved = ctx.topicProfile.resolver.resolve(key);
4758
+ // §5.3 — reads name their staleness: while a transfer pull from the
4759
+ // previous owner is pending, the local entry is served (a slightly-stale
4760
+ // operator pin beats defaults) but flagged "possibly stale".
4761
+ let pendingTransferPull = false;
4762
+ try {
4763
+ pendingTransferPull = ctx.topicProfile.carrier?.hasPendingPull(key) ?? false;
4764
+ }
4765
+ catch {
4766
+ /* @silent-fallback-ok: the staleness flag is read-surface annotation only — a carrier read error must never fail the profile read; the flag defaults to false (TOPIC-PROFILE-SPEC §5.3) */
4767
+ }
4768
+ res.json({
4769
+ topicId: key,
4770
+ resolved: {
4771
+ framework: resolved.framework,
4772
+ model: resolved.model ?? null,
4773
+ modelTier: resolved.modelTier,
4774
+ thinkingMode: resolved.thinkingMode ?? null,
4775
+ escalationOverride: resolved.escalationOverride,
4776
+ sources: resolved.sources,
4777
+ },
4778
+ // Once-per-transition fallback notices that fired on THIS resolution
4779
+ // (the read consumed them — surfaced here rather than dropped).
4780
+ notices: resolved.notices,
4781
+ pin: entry?.current ?? null,
4782
+ previous: entry?.previous ?? null,
4783
+ parked: entry?.parked ?? null,
4784
+ intendedProfile: entry?.intendedProfile ?? null,
4785
+ breakerCount: entry?.breakerCount ?? 0,
4786
+ // §5.3 staleness annotation: true while a pull from this topic's
4787
+ // previous owner is pending — the entry above is "as of when this topic
4788
+ // last lived here — possibly stale".
4789
+ pendingTransferPull,
4790
+ ...(pendingTransferPull && entry?.current
4791
+ ? { staleness: 'pending-transfer-pull — entry is as of when this topic last lived on this machine (possibly stale)' }
4792
+ : {}),
4793
+ });
4794
+ });
4795
+ const requireTopicProfileWrite = (req, res) => {
4796
+ if (req.headers['x-instar-request'] !== '1') {
4797
+ res.status(403).json({ error: 'Topic-profile writes require the X-Instar-Request: 1 intent header' });
4798
+ return null;
4799
+ }
4800
+ if (!TOPIC_PROFILE_KEY_RE.test(req.params.topicId)) {
4801
+ res.status(400).json({ error: 'Invalid topic key — numeric topic id or slack:<channel>[:<thread>]' });
4802
+ return null;
4803
+ }
4804
+ if (!ctx.topicProfile) {
4805
+ res.status(503).json({ error: 'Topic profiles not wired on this server' });
4806
+ return null;
4807
+ }
4808
+ return { key: req.params.topicId, surface: ctx.topicProfile.surface };
4809
+ };
4810
+ router.post('/topic-profile/:topicId', async (req, res) => {
4811
+ const gate = requireTopicProfileWrite(req, res);
4812
+ if (!gate)
4813
+ return;
4814
+ const body = (req.body ?? {});
4815
+ const patch = {};
4816
+ for (const field of ['framework', 'model', 'modelTier', 'thinkingMode', 'escalationOverride']) {
4817
+ if (!(field in body))
4818
+ continue;
4819
+ const value = body[field];
4820
+ if (value !== null && typeof value !== 'string') {
4821
+ res.status(400).json({ error: `"${field}" must be a string or null` });
4822
+ return;
4823
+ }
4824
+ patch[field] = value;
4825
+ }
4826
+ // §10.1: a body-supplied updatedBy is IGNORED — the principal is the
4827
+ // shared token, stamped server-side as 'api-token'.
4828
+ try {
4829
+ const result = await gate.surface.applyWrite({
4830
+ topicKey: gate.key,
4831
+ patch,
4832
+ principal: { kind: 'token' },
4833
+ origin: 'http',
4834
+ });
4835
+ if (!result.ok) {
4836
+ res.status(topicProfileRefusalStatus(result.refusal?.reason)).json({
4837
+ ok: false,
4838
+ reason: result.refusal?.reason,
4839
+ ...(result.refusal?.validation
4840
+ ? { validation: { field: result.refusal.validation.field, failure: result.refusal.validation.failure, reason: result.refusal.validation.reason } }
4841
+ : {}),
4842
+ message: result.reply,
4843
+ });
4844
+ return;
4845
+ }
4846
+ res.json({
4847
+ ok: true,
4848
+ appliedLive: result.appliedLive ?? [],
4849
+ shadowed: result.shadowed ?? [],
4850
+ refusedFields: result.refusedFields ?? [],
4851
+ noop: result.noop ?? false,
4852
+ supersededParked: result.supersededParked ?? false,
4853
+ message: result.reply,
4854
+ pin: ctx.topicProfile?.store.resolve(gate.key) ?? null,
4855
+ });
4856
+ }
4857
+ catch (err) {
4858
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
4859
+ }
4860
+ });
4861
+ // §10.1 propose lane — registers an AGENT-COMPOSED structured delta in the
4862
+ // server-side slot and sends the SERVER-rendered echo to the topic; the
4863
+ // operator's first-party "yes" (recognized by the ingress parse) converts
4864
+ // it to the operator-attributed write. Never writes the store itself.
4865
+ router.post('/topic-profile/:topicId/propose', async (req, res) => {
4866
+ const gate = requireTopicProfileWrite(req, res);
4867
+ if (!gate)
4868
+ return;
4869
+ if (!ctx.topicProfile || !ctx.telegram || !/^\d+$/.test(gate.key)) {
4870
+ res.status(409).json({ error: 'Propose-confirm needs a platform-bound numeric topic on this server' });
4871
+ return;
4872
+ }
4873
+ const body = (req.body ?? {});
4874
+ const patch = {};
4875
+ for (const field of ['framework', 'model', 'modelTier', 'thinkingMode', 'escalationOverride']) {
4876
+ if (!(field in body))
4877
+ continue;
4878
+ const value = body[field];
4879
+ if (value !== null && typeof value !== 'string') {
4880
+ res.status(400).json({ error: `"${field}" must be a string or null` });
4881
+ return;
4882
+ }
4883
+ patch[field] = value;
4884
+ }
4885
+ const rendered = gate.surface.renderProposalEcho(gate.key, patch);
4886
+ if (!rendered.ok) {
4887
+ res.status(400).json({ ok: false, message: rendered.reply });
4888
+ return;
4889
+ }
4890
+ const armed = ctx.topicProfile.confirmSlots.arm(gate.key, 'propose-confirm', rendered.patch, rendered.echo, 'agent-composed');
4891
+ if (!armed.ok) {
4892
+ res.status(429).json({ ok: false, reason: armed.reason, message: 'Proposal churn cooldown — re-state the intent later' });
4893
+ return;
4894
+ }
4895
+ try {
4896
+ const sent = await ctx.telegram.sendToTopic(Number(gate.key), rendered.echo);
4897
+ ctx.topicProfile.confirmSlots.recordEchoMessageId(gate.key, sent.messageId);
4898
+ res.json({ ok: true, echo: rendered.echo, superseded: armed.superseded });
4899
+ }
4900
+ catch (err) {
4901
+ // Echo undelivered — tear the slot down (an unseen echo must never be
4902
+ // confirmable; matchConfirm would refuse on no-echo-id anyway).
4903
+ ctx.topicProfile.confirmSlots.disarm(gate.key);
4904
+ res.status(502).json({ ok: false, error: `Echo delivery failed: ${err instanceof Error ? err.message : String(err)}` });
4905
+ }
4906
+ });
4907
+ // §10.3 / §5.2(b) recovery surfaces (token-trust like the main POST).
4908
+ router.post('/topic-profile/:topicId/undo', async (req, res) => {
4909
+ const gate = requireTopicProfileWrite(req, res);
4910
+ if (!gate)
4911
+ return;
4912
+ const result = await gate.surface.undo({ topicKey: gate.key, principal: { kind: 'token' }, origin: 'http' });
4913
+ if (!result.ok) {
4914
+ res.status(topicProfileRefusalStatus(result.refusal?.reason)).json({ ok: false, reason: result.refusal?.reason, message: result.reply });
4915
+ return;
4916
+ }
4917
+ res.json({ ok: true, message: result.reply, pin: ctx.topicProfile?.store.resolve(gate.key) ?? null });
4918
+ });
4919
+ router.post('/topic-profile/:topicId/clear', async (req, res) => {
4920
+ const gate = requireTopicProfileWrite(req, res);
4921
+ if (!gate)
4922
+ return;
4923
+ const result = await gate.surface.clear({ topicKey: gate.key, principal: { kind: 'token' }, origin: 'http' });
4924
+ if (!result.ok) {
4925
+ res.status(topicProfileRefusalStatus(result.refusal?.reason)).json({ ok: false, reason: result.refusal?.reason, message: result.reply });
4926
+ return;
4927
+ }
4928
+ res.json({ ok: true, message: result.reply, pin: ctx.topicProfile?.store.resolve(gate.key) ?? null });
4929
+ });
4930
+ router.post('/topic-profile/:topicId/reapply', async (req, res) => {
4931
+ const gate = requireTopicProfileWrite(req, res);
4932
+ if (!gate)
4933
+ return;
4934
+ const confirmed = (req.body ?? {}).confirm === true;
4935
+ const result = await gate.surface.reapply({ topicKey: gate.key, principal: { kind: 'token' }, origin: 'http', confirmed });
4936
+ if (!result.ok) {
4937
+ res.status(topicProfileRefusalStatus(result.refusal?.reason)).json({
4938
+ ok: false,
4939
+ reason: result.refusal?.reason,
4940
+ needsConfirm: result.needsConfirm ?? false,
4941
+ message: result.reply,
4942
+ });
4943
+ return;
4944
+ }
4945
+ res.json({ ok: true, message: result.reply, pin: ctx.topicProfile?.store.resolve(gate.key) ?? null });
4946
+ });
4569
4947
  // Rate limit session spawning — each session is a real Claude Code process.
4570
4948
  // Default: 10 spawns per 60 seconds, which is generous for normal use.
4571
4949
  const spawnLimiter = rateLimiter(60_000, 10);
@@ -8971,6 +9349,21 @@ export function createRoutes(ctx) {
8971
9349
  };
8972
9350
  if (casAndJournal({ type: 'place', machineId: target }, 'tplace')) {
8973
9351
  placedOwnership = casAndJournal({ type: 'claim', machineId: target }, 'tclaim');
9352
+ // TOPIC-PROFILE-SPEC §5.3 acquire seam (deterministic-transfer arm):
9353
+ // when THIS machine is the transfer target, the landed tclaim is an
9354
+ // ownership ACQUIRE — pull the topic's profile from its previous
9355
+ // owner. (target !== self: the target machine fires its own acquire
9356
+ // seam when the first forwarded message lands — deliverMessage
9357
+ // onAccepted; firing here would stage a pull on a non-owner, which
9358
+ // the carrier's apply-time ownership recheck would just discard.)
9359
+ if (placedOwnership && target === self && prevOwner && prevOwner !== self) {
9360
+ try {
9361
+ ctx.topicProfile?.carrier?.onTopicAcquired(topicId, prevOwner);
9362
+ }
9363
+ catch {
9364
+ /* @silent-fallback-ok: the §5.3 pull is fire-and-forget enrichment of the transfer — a carrier failure must never fail the transfer itself; the durable retry ledger re-files on the next acquire/tick (TOPIC-PROFILE-SPEC §5.3) */
9365
+ }
9366
+ }
8974
9367
  }
8975
9368
  else {
8976
9369
  // A resting 'placing' record already naming the target (legacy pre-fix
@@ -8979,6 +9372,15 @@ export function createRoutes(ctx) {
8979
9372
  const cur = ctx.sessionOwnershipRegistry.read(topicId);
8980
9373
  if (cur?.status === 'placing' && cur.ownerMachineId === target) {
8981
9374
  placedOwnership = casAndJournal({ type: 'claim', machineId: target }, 'tclaim');
9375
+ // §5.3 acquire seam — same self-target arm as the place→claim path above.
9376
+ if (placedOwnership && target === self && prevOwner && prevOwner !== self) {
9377
+ try {
9378
+ ctx.topicProfile?.carrier?.onTopicAcquired(topicId, prevOwner);
9379
+ }
9380
+ catch {
9381
+ /* @silent-fallback-ok: fire-and-forget §5.3 pull — never fails the transfer; the durable ledger re-files (TOPIC-PROFILE-SPEC §5.3) */
9382
+ }
9383
+ }
8982
9384
  }
8983
9385
  }
8984
9386
  }
@@ -11409,6 +11811,10 @@ export function createRoutes(ctx) {
11409
11811
  firstName: fromFirstName,
11410
11812
  messageThreadId: topicId,
11411
11813
  viaLifeline: true,
11814
+ // TOPIC-PROFILE-SPEC §10.1 round-5: forwarded content never matches
11815
+ // any profile-ingress recognition. An upgraded lifeline sets this;
11816
+ // its absence (older lifeline) reads as not-forwarded.
11817
+ ...(req.body.forwarded === true ? { forwarded: true } : {}),
11412
11818
  },
11413
11819
  };
11414
11820
  try {