instar 1.3.489 → 1.3.490
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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +979 -28
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +23 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/CodexResumeMap.d.ts +95 -0
- package/dist/core/CodexResumeMap.d.ts.map +1 -0
- package/dist/core/CodexResumeMap.js +283 -0
- package/dist/core/CodexResumeMap.js.map +1 -0
- package/dist/core/MeshRpc.d.ts +3 -0
- package/dist/core/MeshRpc.d.ts.map +1 -1
- package/dist/core/MeshRpc.js +5 -0
- package/dist/core/MeshRpc.js.map +1 -1
- package/dist/core/ModelSwapService.d.ts +26 -11
- package/dist/core/ModelSwapService.d.ts.map +1 -1
- package/dist/core/ModelSwapService.js +59 -21
- package/dist/core/ModelSwapService.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +16 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +62 -2
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/SessionManager.d.ts +4 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +3 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +36 -7
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +90 -29
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/TopicProfileOrchestrator.d.ts +480 -0
- package/dist/core/TopicProfileOrchestrator.d.ts.map +1 -0
- package/dist/core/TopicProfileOrchestrator.js +1404 -0
- package/dist/core/TopicProfileOrchestrator.js.map +1 -0
- package/dist/core/TopicProfileResolver.d.ts +104 -0
- package/dist/core/TopicProfileResolver.d.ts.map +1 -0
- package/dist/core/TopicProfileResolver.js +231 -0
- package/dist/core/TopicProfileResolver.js.map +1 -0
- package/dist/core/TopicProfileStore.d.ts +308 -0
- package/dist/core/TopicProfileStore.d.ts.map +1 -0
- package/dist/core/TopicProfileStore.js +781 -0
- package/dist/core/TopicProfileStore.js.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts +227 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -0
- package/dist/core/TopicProfileTransferCarrier.js +533 -0
- package/dist/core/TopicProfileTransferCarrier.js.map +1 -0
- package/dist/core/TopicResumeMap.d.ts +67 -1
- package/dist/core/TopicResumeMap.d.ts.map +1 -1
- package/dist/core/TopicResumeMap.js +114 -1
- package/dist/core/TopicResumeMap.js.map +1 -1
- package/dist/core/classifyProfileChange.d.ts +83 -0
- package/dist/core/classifyProfileChange.d.ts.map +1 -0
- package/dist/core/classifyProfileChange.js +274 -0
- package/dist/core/classifyProfileChange.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/frameworkSessionLaunch.d.ts +26 -0
- package/dist/core/frameworkSessionLaunch.d.ts.map +1 -1
- package/dist/core/frameworkSessionLaunch.js +58 -0
- package/dist/core/frameworkSessionLaunch.js.map +1 -1
- package/dist/core/slackRefreshBinding.d.ts +83 -0
- package/dist/core/slackRefreshBinding.d.ts.map +1 -0
- package/dist/core/slackRefreshBinding.js +54 -0
- package/dist/core/slackRefreshBinding.js.map +1 -0
- package/dist/core/topicProfileIngress.d.ts +128 -0
- package/dist/core/topicProfileIngress.d.ts.map +1 -0
- package/dist/core/topicProfileIngress.js +249 -0
- package/dist/core/topicProfileIngress.js.map +1 -0
- package/dist/core/topicProfileValidation.d.ts +109 -0
- package/dist/core/topicProfileValidation.d.ts.map +1 -0
- package/dist/core/topicProfileValidation.js +247 -0
- package/dist/core/topicProfileValidation.js.map +1 -0
- package/dist/core/topicProfileWriteSurface.d.ts +222 -0
- package/dist/core/topicProfileWriteSurface.d.ts.map +1 -0
- package/dist/core/topicProfileWriteSurface.js +631 -0
- package/dist/core/topicProfileWriteSurface.js.map +1 -0
- package/dist/core/types.d.ts +37 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +6 -0
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +10 -1
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +41 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +9 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +32 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +52 -3
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +17 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +271 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +66 -66
- package/src/scaffold/templates.ts +9 -0
- package/upgrades/1.3.490.md +34 -0
- package/upgrades/side-effects/topic-profile.md +142 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TopicProfileWriteSurface — the ONE platform-agnostic write engine behind all
|
|
3
|
+
* Topic Profile write surfaces (TOPIC-PROFILE-SPEC §5.2, §10.1–§10.4):
|
|
4
|
+
* conversational ingress, the /topic command, the rewired /route command, the
|
|
5
|
+
* token-trust HTTP route, the §10.1 propose-confirm lane, and the §5.2(b)
|
|
6
|
+
* recovery writes (undo / clear / re-apply).
|
|
7
|
+
*
|
|
8
|
+
* Regime law implemented here (the spec's hardest-won invariants):
|
|
9
|
+
*
|
|
10
|
+
* - §5.2(d): FRAMEWORK-arm writes via the pre-existing `/route` surface (and
|
|
11
|
+
* equivalent conversational switches) are EXEMPT from BOTH gating knobs —
|
|
12
|
+
* always a LIVE store write, never routed to the §14 dry-run shadow — and
|
|
13
|
+
* wherever the new §8 orchestration is not fully live (`enabled:false` OR
|
|
14
|
+
* `dryRun:true`), the switch is served end-to-end by the LEGACY path,
|
|
15
|
+
* byte-for-byte today's shipped behavior (immediate kill + CONTINUATION
|
|
16
|
+
* respawn with the resume-UUID drop).
|
|
17
|
+
* - The NEW axes (model / modelTier / thinkingMode / escalationOverride) are
|
|
18
|
+
* gated: refused while disabled; shadowed (`intendedProfile`) under
|
|
19
|
+
* dryRun; live only when `enabled && !dryRun`.
|
|
20
|
+
* - §5.2(b): the recovery writes — re-apply and CLEAR — are LIVE writes in
|
|
21
|
+
* EVERY regime (never refused as new pins, never shadowed), but their
|
|
22
|
+
* APPLICATION arm is regime-governed: outside the fully-live regime there
|
|
23
|
+
* is NO profile-triggered kill — the write applies at the next natural
|
|
24
|
+
* spawn / boot-sweep reconcile, and the confirmation says so out loud.
|
|
25
|
+
* - §10.1: `updatedBy` is stamped server-side from the VERIFIED principal —
|
|
26
|
+
* operator-attributed only on the platform-authenticated surfaces; HTTP is
|
|
27
|
+
* token-trust (`updatedBy:'api-token'`), never operator-attributed, and a
|
|
28
|
+
* body-supplied updatedBy is ignored by construction (it never reaches
|
|
29
|
+
* this module). Writes refuse when the sender is not the topic's bound
|
|
30
|
+
* operator (or, for token writes, when NO bound operator exists).
|
|
31
|
+
* - §8: EVERY accepted write discloses — one line, delta-carrying, carrying
|
|
32
|
+
* the audit sequence stamp so the relay's exact-duplicate window can never
|
|
33
|
+
* silently swallow a repeat notice. The undo snapshot (`previous`) shifts
|
|
34
|
+
* once per disclosed write (no §8 coalescing window exists until the
|
|
35
|
+
* orchestrator serves the fully-live regime, so every accepted write here
|
|
36
|
+
* is individually disclosed — the spec's outside-a-window cadence).
|
|
37
|
+
*
|
|
38
|
+
* §8 ORCHESTRATION SEAM (TODO-wire): `deps.orchestrator` is the call site for
|
|
39
|
+
* TopicProfileOrchestrator (built in parallel). When present AND the regime
|
|
40
|
+
* is fully-live, profile-triggered respawns hand off to it (debounce, idle
|
|
41
|
+
* re-confirm, classification, parking). Until it is wired:
|
|
42
|
+
* - framework switches are served by the legacy respawn in every regime
|
|
43
|
+
* (today's shipped behavior — the §5.2(d) contract, audited
|
|
44
|
+
* `orchestration-unavailable` when it happens in the fully-live regime);
|
|
45
|
+
* - new-axis live writes apply at the next natural spawn, told out loud.
|
|
46
|
+
*/
|
|
47
|
+
import { ProfileValidationRefusal, ProfileLockTimeoutError, FlushRefusedError } from './TopicProfileStore.js';
|
|
48
|
+
import { validateProfileFields, } from './topicProfileValidation.js';
|
|
49
|
+
const NEW_AXES = ['model', 'modelTier', 'thinkingMode', 'escalationOverride'];
|
|
50
|
+
const ALL_FIELDS = ['framework', ...NEW_AXES];
|
|
51
|
+
const DEFAULT_REAPPLY_COOLDOWN_MS = 600_000;
|
|
52
|
+
export class TopicProfileWriteSurface {
|
|
53
|
+
deps;
|
|
54
|
+
now;
|
|
55
|
+
constructor(deps) {
|
|
56
|
+
this.deps = deps;
|
|
57
|
+
this.now = deps.now ?? (() => Date.now());
|
|
58
|
+
}
|
|
59
|
+
// ── the main write path ───────────────────────────────────────────────────
|
|
60
|
+
async applyWrite(req) {
|
|
61
|
+
const topicKey = String(req.topicKey);
|
|
62
|
+
const regime = this.deps.regime();
|
|
63
|
+
// ── §10.1 principal / bound-operator gate ──────────────────────────────
|
|
64
|
+
const authRefusal = this.authorize(topicKey, req.principal, req.origin);
|
|
65
|
+
if (authRefusal)
|
|
66
|
+
return authRefusal;
|
|
67
|
+
const updatedBy = this.principalStamp(req.principal);
|
|
68
|
+
// ── §10.2 closed-enum clamp (every field, before persist) ──────────────
|
|
69
|
+
const resolved = this.deps.resolver.resolve(topicKey);
|
|
70
|
+
const validated = validateProfileFields(req.patch, resolved.framework);
|
|
71
|
+
if (!validated.ok) {
|
|
72
|
+
this.deps.audit({
|
|
73
|
+
type: 'write',
|
|
74
|
+
outcome: 'refused',
|
|
75
|
+
reason: `validation:${validated.error.failure}`,
|
|
76
|
+
field: validated.error.field,
|
|
77
|
+
rejectedPrefix: validated.error.rejectedPrefix,
|
|
78
|
+
rejectedLength: validated.error.rejectedLength,
|
|
79
|
+
topic: topicKey,
|
|
80
|
+
principal: updatedBy,
|
|
81
|
+
origin: req.origin,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reply: `Can't apply that: ${validated.error.reason}. The profile is unchanged.`,
|
|
86
|
+
refusal: { reason: 'validation', validation: validated.error },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const patch = validated.patch;
|
|
90
|
+
// ── §5.2 local-model-binding precedence (cloud pin refused) ────────────
|
|
91
|
+
if ((patch.model != null || patch.modelTier != null) && this.deps.localModelBinding(topicKey)) {
|
|
92
|
+
this.deps.audit({
|
|
93
|
+
type: 'write', outcome: 'refused', reason: 'local-model-binding-active',
|
|
94
|
+
topic: topicKey, principal: updatedBy, origin: req.origin,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reply: `This topic has a local-model binding — clear it first (say "/local-model off") to pin a cloud model.`,
|
|
99
|
+
refusal: { reason: 'local-model-binding-active' },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// ── mixed-delta split (§5.2(d) + §10.1 round-12) ───────────────────────
|
|
103
|
+
const frameworkArm = {};
|
|
104
|
+
if (patch.framework !== undefined)
|
|
105
|
+
frameworkArm.framework = patch.framework;
|
|
106
|
+
const liveNewAxes = {};
|
|
107
|
+
const shadowNewAxes = {};
|
|
108
|
+
const refusedFields = [];
|
|
109
|
+
for (const field of NEW_AXES) {
|
|
110
|
+
const value = patch[field];
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
if (value === null) {
|
|
114
|
+
// §5.2(b): clearing a pin is a recovery write — permitted (LIVE) in
|
|
115
|
+
// every regime, never shadowed.
|
|
116
|
+
liveNewAxes[field] = null;
|
|
117
|
+
}
|
|
118
|
+
else if (!regime.enabled) {
|
|
119
|
+
refusedFields.push(field);
|
|
120
|
+
}
|
|
121
|
+
else if (regime.dryRun) {
|
|
122
|
+
shadowNewAxes[field] = value;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
liveNewAxes[field] = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const frameworkChanged = frameworkArm.framework !== undefined
|
|
129
|
+
&& (this.deps.store.resolve(topicKey)?.framework ?? null) !== frameworkArm.framework;
|
|
130
|
+
// ── live store write (framework arm — always — plus live new axes) ─────
|
|
131
|
+
const livePatch = { ...frameworkArm, ...liveNewAxes };
|
|
132
|
+
const liveFields = Object.keys(livePatch);
|
|
133
|
+
const shadowFields = Object.keys(shadowNewAxes);
|
|
134
|
+
if (liveFields.length === 0 && shadowFields.length === 0) {
|
|
135
|
+
if (refusedFields.length > 0) {
|
|
136
|
+
// §5.2 disabled-flag semantics: NEW operator pins are refused while
|
|
137
|
+
// the feature is off (reads still honor existing pins).
|
|
138
|
+
this.deps.audit({
|
|
139
|
+
type: 'write', outcome: 'refused', reason: 'disabled',
|
|
140
|
+
fields: refusedFields, topic: topicKey, principal: updatedBy, origin: req.origin,
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reply: `The ${refusedFields.join('/')} control isn't enabled on this agent — the profile is unchanged.`,
|
|
145
|
+
refusal: { reason: 'disabled' },
|
|
146
|
+
refusedFields,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { ok: false, reply: 'Nothing to change — say what you want set on this topic.', refusal: { reason: 'empty-patch' } };
|
|
150
|
+
}
|
|
151
|
+
const before = this.profileSnapshot(topicKey);
|
|
152
|
+
let supersededParked = false;
|
|
153
|
+
let changed = false;
|
|
154
|
+
if (liveFields.length > 0) {
|
|
155
|
+
try {
|
|
156
|
+
const result = await this.deps.store.mutate(topicKey, { ...livePatch, updatedBy },
|
|
157
|
+
// §5.1 cadence: every accepted write here is individually disclosed
|
|
158
|
+
// (no §8 coalescing window until the orchestrator is live).
|
|
159
|
+
{ shiftPrevious: true });
|
|
160
|
+
changed = result.changed;
|
|
161
|
+
supersededParked = result.supersededParked;
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
return this.writeFailure(err, topicKey, updatedBy, req.origin);
|
|
165
|
+
}
|
|
166
|
+
// §5.3: the mutate's flush durably landed (mutate resolved) — a local
|
|
167
|
+
// operator/HTTP write cancels any pending transfer-pull REPLACE.
|
|
168
|
+
this.notifyLocalWriteDurable(topicKey, req.origin);
|
|
169
|
+
}
|
|
170
|
+
if (shadowFields.length > 0) {
|
|
171
|
+
try {
|
|
172
|
+
// §14: the dry-run shadow records the intent; resolution never reads it.
|
|
173
|
+
const current = this.deps.store.get(topicKey)?.intendedProfile?.fields ?? {};
|
|
174
|
+
await this.deps.store.setShadow(topicKey, { ...current, ...shadowNewAxes }, updatedBy);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return this.writeFailure(err, topicKey, updatedBy, req.origin);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const after = this.profileSnapshot(topicKey);
|
|
181
|
+
const seq = this.deps.audit({
|
|
182
|
+
type: 'write',
|
|
183
|
+
outcome: 'accepted',
|
|
184
|
+
topic: topicKey,
|
|
185
|
+
principal: updatedBy,
|
|
186
|
+
origin: req.origin,
|
|
187
|
+
...(req.agentComposedPayload ? { payloadProvenance: 'agent-composed' } : {}),
|
|
188
|
+
appliedLive: liveFields,
|
|
189
|
+
shadowed: shadowFields,
|
|
190
|
+
refusedFields,
|
|
191
|
+
old: before,
|
|
192
|
+
new: after,
|
|
193
|
+
...(supersededParked ? { supersededParked: true } : {}),
|
|
194
|
+
}) ?? new Date(this.now()).toISOString();
|
|
195
|
+
// ── application arm ─────────────────────────────────────────────────────
|
|
196
|
+
const fullyLive = regime.enabled && !regime.dryRun;
|
|
197
|
+
let legacyRespawned = false;
|
|
198
|
+
let respawnNote = '';
|
|
199
|
+
if (frameworkChanged && changed) {
|
|
200
|
+
if (fullyLive && this.deps.orchestrator) {
|
|
201
|
+
// §8 fully-live orchestration (debounce, busy-refusal, parking).
|
|
202
|
+
await this.deps.orchestrator.onProfileWrite(topicKey, { frameworkChanged: true, origin: req.origin });
|
|
203
|
+
respawnNote = ' Applying shortly (waiting for an idle moment).';
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// §5.2(d): the legacy path serves the switch byte-for-byte — immediate
|
|
207
|
+
// kill + CONTINUATION respawn, resume-UUID drop. In the fully-live
|
|
208
|
+
// regime with no orchestrator wired this is the keep-working fallback.
|
|
209
|
+
if (fullyLive)
|
|
210
|
+
this.deps.audit({ type: 'orchestration-unavailable', topic: topicKey });
|
|
211
|
+
const respawn = await this.deps.legacyFrameworkRespawn(topicKey);
|
|
212
|
+
legacyRespawned = respawn.respawned;
|
|
213
|
+
respawnNote = respawn.respawned
|
|
214
|
+
? ' Session respawned — recent history carries over; the full transcript can\'t follow across frameworks.'
|
|
215
|
+
: respawn.error
|
|
216
|
+
? ` Persisted, but the respawn failed: ${respawn.error} — it takes effect on this topic's next session.`
|
|
217
|
+
: ' Takes effect when a session starts for this topic.';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (Object.keys(liveNewAxes).length > 0 && changed) {
|
|
221
|
+
if (fullyLive && this.deps.orchestrator) {
|
|
222
|
+
await this.deps.orchestrator.onProfileWrite(topicKey, { frameworkChanged: false, origin: req.origin });
|
|
223
|
+
respawnNote = ' Applying shortly (waiting for an idle moment).';
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// No profile-triggered kill outside the fully-live orchestration —
|
|
227
|
+
// told out loud (§5.2(b) precedent: apply-at-next-spawn, boot-sweep
|
|
228
|
+
// reconcile is the backstop).
|
|
229
|
+
respawnNote = ' Takes effect at this topic\'s next session restart.';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// ── §8 disclosure (delta-carrying, audit-stamped) ───────────────────────
|
|
233
|
+
const parts = [];
|
|
234
|
+
if (liveFields.length > 0) {
|
|
235
|
+
parts.push(`${this.renderDelta(before, after)}${respawnNote}`);
|
|
236
|
+
}
|
|
237
|
+
if (shadowFields.length > 0) {
|
|
238
|
+
parts.push(`[dry-run] recorded as an intent, not applied: ${shadowFields.map(f => `${f} → ${String(shadowNewAxes[f])}`).join(', ')} — the new profile machinery is in its observation canary on this agent.`);
|
|
239
|
+
}
|
|
240
|
+
if (refusedFields.length > 0) {
|
|
241
|
+
parts.push(`Not applied (the ${refusedFields.join('/')} control isn't enabled on this agent): re-issue once it's on.`);
|
|
242
|
+
}
|
|
243
|
+
if (supersededParked) {
|
|
244
|
+
parts.push('Your previously parked pin was superseded by this change.');
|
|
245
|
+
}
|
|
246
|
+
if (liveFields.length > 0 && !changed && shadowFields.length === 0 && refusedFields.length === 0) {
|
|
247
|
+
return { ok: true, noop: true, reply: 'Already set — nothing to change.', appliedLive: [] };
|
|
248
|
+
}
|
|
249
|
+
const reply = `${parts.join('\n')}\n(profile change ${seq}${req.origin === 'http' ? ', via API' : ''})`;
|
|
250
|
+
if (!req.discloseInReply && (liveFields.length > 0 || shadowFields.length > 0)) {
|
|
251
|
+
// §8: token-trust/HTTP writes (and any caller whose reply isn't the
|
|
252
|
+
// topic conversation) post the disclosure to the topic itself.
|
|
253
|
+
await this.deps.disclose(topicKey, reply).catch(() => { });
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
reply,
|
|
258
|
+
appliedLive: liveFields,
|
|
259
|
+
shadowed: shadowFields,
|
|
260
|
+
refusedFields,
|
|
261
|
+
supersededParked,
|
|
262
|
+
legacyRespawned,
|
|
263
|
+
noop: liveFields.length > 0 ? !changed : undefined,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// ── §10.3 undo ────────────────────────────────────────────────────────────
|
|
267
|
+
async undo(req) {
|
|
268
|
+
const topicKey = String(req.topicKey);
|
|
269
|
+
const authRefusal = this.authorize(topicKey, req.principal, req.origin);
|
|
270
|
+
if (authRefusal)
|
|
271
|
+
return authRefusal;
|
|
272
|
+
const updatedBy = this.principalStamp(req.principal);
|
|
273
|
+
const previous = this.deps.store.previousFor(topicKey);
|
|
274
|
+
const current = this.deps.store.resolve(topicKey);
|
|
275
|
+
if (current === null) {
|
|
276
|
+
return { ok: false, reply: 'Nothing to undo yet — this topic has no recorded profile change.', refusal: { reason: 'nothing-to-undo' } };
|
|
277
|
+
}
|
|
278
|
+
if (previous === null && current.updatedBy.startsWith('system:')) {
|
|
279
|
+
// §5.1: a legacy-seeded entry initializes previous:null — there is no
|
|
280
|
+
// operator-disclosed change to restore; refused plainly.
|
|
281
|
+
return { ok: false, reply: 'Nothing to undo yet — there\'s no earlier profile snapshot for this topic.', refusal: { reason: 'nothing-to-undo' } };
|
|
282
|
+
}
|
|
283
|
+
// previous === null after a FIRST disclosed pin is a GENUINE snapshot
|
|
284
|
+
// ("no profile" — the pre-pin defaults): the shift recorded current=null
|
|
285
|
+
// at write time, so undo of a first (possibly hostile) pin clears it.
|
|
286
|
+
// Full-field restore (undo is a REPLACE-shaped write: absent fields clear).
|
|
287
|
+
const patch = {};
|
|
288
|
+
for (const field of ALL_FIELDS) {
|
|
289
|
+
patch[field] = previous
|
|
290
|
+
? (previous[field] ?? null)
|
|
291
|
+
: null;
|
|
292
|
+
}
|
|
293
|
+
const frameworkChanged = (current.framework ?? null) !== (previous?.framework ?? null);
|
|
294
|
+
const before = this.profileSnapshot(topicKey);
|
|
295
|
+
let changed = false;
|
|
296
|
+
try {
|
|
297
|
+
const result = await this.deps.store.mutate(topicKey, { ...patch, updatedBy }, { shiftPrevious: true });
|
|
298
|
+
changed = result.changed;
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
return this.writeFailure(err, topicKey, updatedBy, req.origin);
|
|
302
|
+
}
|
|
303
|
+
// §5.3 cancel marker — the undo's mutate flushed durably.
|
|
304
|
+
this.notifyLocalWriteDurable(topicKey, req.origin);
|
|
305
|
+
if (!changed) {
|
|
306
|
+
return { ok: true, noop: true, reply: 'You\'re already back where you started — nothing to undo.' };
|
|
307
|
+
}
|
|
308
|
+
const after = this.profileSnapshot(topicKey);
|
|
309
|
+
const seq = this.deps.audit({
|
|
310
|
+
type: 'undo', outcome: 'accepted', topic: topicKey, principal: updatedBy,
|
|
311
|
+
origin: req.origin, old: before, new: after,
|
|
312
|
+
}) ?? new Date(this.now()).toISOString();
|
|
313
|
+
const regime = this.deps.regime();
|
|
314
|
+
const fullyLive = regime.enabled && !regime.dryRun;
|
|
315
|
+
let note = '';
|
|
316
|
+
if (frameworkChanged) {
|
|
317
|
+
if (fullyLive && this.deps.orchestrator) {
|
|
318
|
+
await this.deps.orchestrator.onProfileWrite(topicKey, { frameworkChanged: true, origin: req.origin });
|
|
319
|
+
note = ' Applying shortly.';
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// §8 round-12: a legacy-served switch dropped the resume UUID, so the
|
|
323
|
+
// undo recovers via CONTINUATION — recent-context loss, NAMED in the
|
|
324
|
+
// reply (nothing parked to un-park on the legacy path).
|
|
325
|
+
const respawn = await this.deps.legacyFrameworkRespawn(topicKey);
|
|
326
|
+
note = respawn.respawned
|
|
327
|
+
? ' Fresh thread — the old conversation can\'t be resumed across that switch, so I\'m carrying recent history only.'
|
|
328
|
+
: ' Takes effect when a session starts for this topic.';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else if (fullyLive && this.deps.orchestrator) {
|
|
332
|
+
await this.deps.orchestrator.onProfileWrite(topicKey, { frameworkChanged: false, origin: req.origin });
|
|
333
|
+
note = ' Applying shortly.';
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
note = ' Takes effect at this topic\'s next session restart.';
|
|
337
|
+
}
|
|
338
|
+
const reply = `Undone — ${this.renderDelta(before, after)}.${note}\n(profile change ${seq})`;
|
|
339
|
+
if (!req.discloseInReply)
|
|
340
|
+
await this.deps.disclose(topicKey, reply).catch(() => { });
|
|
341
|
+
return { ok: true, reply, appliedLive: Object.keys(patch) };
|
|
342
|
+
}
|
|
343
|
+
// ── §5.2(b) recovery writes: clear + re-apply ────────────────────────────
|
|
344
|
+
/** CLEAR — a recovery write: LIVE in every regime, never shadowed. */
|
|
345
|
+
async clear(req) {
|
|
346
|
+
const topicKey = String(req.topicKey);
|
|
347
|
+
const authRefusal = this.authorize(topicKey, req.principal, req.origin);
|
|
348
|
+
if (authRefusal)
|
|
349
|
+
return authRefusal;
|
|
350
|
+
const updatedBy = this.principalStamp(req.principal);
|
|
351
|
+
const before = this.profileSnapshot(topicKey);
|
|
352
|
+
const hadFramework = this.deps.store.resolve(topicKey)?.framework != null;
|
|
353
|
+
let changed = false;
|
|
354
|
+
try {
|
|
355
|
+
const result = await this.deps.store.mutate(topicKey, { framework: null, model: null, modelTier: null, thinkingMode: null, escalationOverride: null, updatedBy }, { shiftPrevious: true });
|
|
356
|
+
changed = result.changed;
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
return this.writeFailure(err, topicKey, updatedBy, req.origin);
|
|
360
|
+
}
|
|
361
|
+
// §5.3 cancel marker — the clear's mutate flushed durably.
|
|
362
|
+
this.notifyLocalWriteDurable(topicKey, req.origin);
|
|
363
|
+
if (!changed) {
|
|
364
|
+
return { ok: true, noop: true, reply: 'This topic has no profile pins — nothing to clear.' };
|
|
365
|
+
}
|
|
366
|
+
const seq = this.deps.audit({
|
|
367
|
+
type: 'clear', outcome: 'accepted', topic: topicKey, principal: updatedBy,
|
|
368
|
+
origin: req.origin, old: before,
|
|
369
|
+
}) ?? new Date(this.now()).toISOString();
|
|
370
|
+
// §5.2(b): the APPLICATION arm is regime-governed — no profile-triggered
|
|
371
|
+
// kill outside the fully-live orchestration; told out loud.
|
|
372
|
+
const regime = this.deps.regime();
|
|
373
|
+
const fullyLive = regime.enabled && !regime.dryRun;
|
|
374
|
+
let note;
|
|
375
|
+
if (fullyLive && this.deps.orchestrator) {
|
|
376
|
+
await this.deps.orchestrator.onProfileWrite(topicKey, { frameworkChanged: hadFramework, origin: req.origin });
|
|
377
|
+
note = 'Applying shortly.';
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
note = 'Cleared — takes effect at this topic\'s next session restart.';
|
|
381
|
+
}
|
|
382
|
+
const reply = `${note} This topic is back on the defaults.\n(profile change ${seq})`;
|
|
383
|
+
if (!req.discloseInReply)
|
|
384
|
+
await this.deps.disclose(topicKey, reply).catch(() => { });
|
|
385
|
+
return { ok: true, reply, appliedLive: [...ALL_FIELDS] };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* RE-APPLY the §10.4 parked (intended-but-unhealthy) pin — a recovery
|
|
389
|
+
* write: LIVE in every regime. Carries the §10.4 cooldown guard: re-applying
|
|
390
|
+
* the same profile that just tripped the breaker requires an explicit
|
|
391
|
+
* confirm (the caller arms the shared confirm slot with the returned echo).
|
|
392
|
+
*/
|
|
393
|
+
async reapply(req) {
|
|
394
|
+
const topicKey = String(req.topicKey);
|
|
395
|
+
const authRefusal = this.authorize(topicKey, req.principal, req.origin);
|
|
396
|
+
if (authRefusal)
|
|
397
|
+
return authRefusal;
|
|
398
|
+
const updatedBy = this.principalStamp(req.principal);
|
|
399
|
+
const parked = this.deps.store.parkedFor(topicKey);
|
|
400
|
+
if (!parked) {
|
|
401
|
+
// §10.4 supersession wording: after a new deliberate pin, re-apply has
|
|
402
|
+
// nothing parked.
|
|
403
|
+
const hasProfile = this.deps.store.resolve(topicKey) !== null;
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
reply: hasProfile
|
|
407
|
+
? 'Nothing parked — you\'ve since set a new profile for this topic.'
|
|
408
|
+
: 'Nothing parked on this topic.',
|
|
409
|
+
refusal: { reason: 'nothing-parked' },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const cooldownMs = this.deps.reapplyCooldownMs ?? DEFAULT_REAPPLY_COOLDOWN_MS;
|
|
413
|
+
const parkedAgeMs = this.now() - Date.parse(parked.parkedAt);
|
|
414
|
+
if (!req.confirmed && Number.isFinite(parkedAgeMs) && parkedAgeMs < cooldownMs) {
|
|
415
|
+
const echo = `This exact profile failed to launch ${Math.max(1, Math.round(parkedAgeMs / 60_000))} minute(s) ago (${parked.reason}) — apply it anyway?`;
|
|
416
|
+
this.deps.audit({ type: 'reapply', outcome: 'needs-confirm', topic: topicKey, principal: updatedBy, origin: req.origin });
|
|
417
|
+
return { ok: false, needsConfirm: true, reply: echo, refusal: { reason: 'cooldown-confirm-required' } };
|
|
418
|
+
}
|
|
419
|
+
const patch = {};
|
|
420
|
+
for (const field of ALL_FIELDS) {
|
|
421
|
+
patch[field] = parked.profile[field] ?? null;
|
|
422
|
+
}
|
|
423
|
+
const frameworkChanged = (this.deps.store.resolve(topicKey)?.framework ?? null) !== (parked.profile.framework ?? null);
|
|
424
|
+
const before = this.profileSnapshot(topicKey);
|
|
425
|
+
try {
|
|
426
|
+
// The operator-attributed mutate atomically clears the parked state +
|
|
427
|
+
// breaker counter (store supersession discipline).
|
|
428
|
+
await this.deps.store.mutate(topicKey, { ...patch, updatedBy }, { shiftPrevious: true });
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
return this.writeFailure(err, topicKey, updatedBy, req.origin);
|
|
432
|
+
}
|
|
433
|
+
// §5.3 cancel marker — the re-apply's mutate flushed durably.
|
|
434
|
+
this.notifyLocalWriteDurable(topicKey, req.origin);
|
|
435
|
+
const after = this.profileSnapshot(topicKey);
|
|
436
|
+
const seq = this.deps.audit({
|
|
437
|
+
type: 'reapply', outcome: 'accepted', topic: topicKey, principal: updatedBy,
|
|
438
|
+
origin: req.origin, ...(req.confirmed ? { cooldownOverridden: true } : {}),
|
|
439
|
+
old: before, new: after,
|
|
440
|
+
}) ?? new Date(this.now()).toISOString();
|
|
441
|
+
const regime = this.deps.regime();
|
|
442
|
+
const fullyLive = regime.enabled && !regime.dryRun;
|
|
443
|
+
let note;
|
|
444
|
+
if (fullyLive && this.deps.orchestrator) {
|
|
445
|
+
await this.deps.orchestrator.onProfileWrite(topicKey, { frameworkChanged, origin: req.origin });
|
|
446
|
+
note = 'Re-applied — applying shortly.';
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
// §5.2(b): recovery writes never profile-kill outside fully-live.
|
|
450
|
+
note = 'Re-applied — takes effect at this topic\'s next session restart.';
|
|
451
|
+
}
|
|
452
|
+
const reply = `${note} (${this.renderDelta(before, after)})\n(profile change ${seq})`;
|
|
453
|
+
if (!req.discloseInReply)
|
|
454
|
+
await this.deps.disclose(topicKey, reply).catch(() => { });
|
|
455
|
+
return { ok: true, reply, appliedLive: Object.keys(patch) };
|
|
456
|
+
}
|
|
457
|
+
// ── §10.1 propose lane (server-rendered echo) ───────────────────────────
|
|
458
|
+
/**
|
|
459
|
+
* Validate + render the SERVER-side echo for an agent-composed proposal.
|
|
460
|
+
* The echo names each arm's fate under the live regime BEFORE the confirm
|
|
461
|
+
* (mixed-delta split, round-12) — what the operator confirms is mechanically
|
|
462
|
+
* what will be written. The caller arms the shared confirm slot with the
|
|
463
|
+
* returned patch+echo and sends the echo to the topic.
|
|
464
|
+
*/
|
|
465
|
+
renderProposalEcho(topicKey, patch) {
|
|
466
|
+
const key = String(topicKey);
|
|
467
|
+
const resolved = this.deps.resolver.resolve(key);
|
|
468
|
+
const validated = validateProfileFields(patch, resolved.framework);
|
|
469
|
+
if (!validated.ok) {
|
|
470
|
+
return { ok: false, reply: `Can't propose that: ${validated.error.reason}.`, validation: validated.error };
|
|
471
|
+
}
|
|
472
|
+
if ((validated.patch.model != null || validated.patch.modelTier != null) && this.deps.localModelBinding(key)) {
|
|
473
|
+
return { ok: false, reply: 'This topic has a local-model binding — clear it first to pin a cloud model.' };
|
|
474
|
+
}
|
|
475
|
+
const regime = this.deps.regime();
|
|
476
|
+
const lines = ['Proposed profile change for this topic:'];
|
|
477
|
+
for (const field of ALL_FIELDS) {
|
|
478
|
+
const value = validated.patch[field];
|
|
479
|
+
if (value === undefined)
|
|
480
|
+
continue;
|
|
481
|
+
const rendered = value === null ? 'cleared' : String(value);
|
|
482
|
+
if (field === 'framework') {
|
|
483
|
+
lines.push(` • framework → ${rendered}: switches now (live)`);
|
|
484
|
+
}
|
|
485
|
+
else if (value === null) {
|
|
486
|
+
lines.push(` • ${field} → ${rendered}: applies now (recovery write, live in every regime)`);
|
|
487
|
+
}
|
|
488
|
+
else if (!regime.enabled) {
|
|
489
|
+
lines.push(` • ${field} → ${rendered}: refused — the ${field} control isn't enabled on this agent`);
|
|
490
|
+
}
|
|
491
|
+
else if (regime.dryRun) {
|
|
492
|
+
lines.push(` • ${field} → ${rendered}: recorded as a dry-run intent (not applied)`);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
lines.push(` • ${field} → ${rendered}: applies after you confirm`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (lines.length === 1)
|
|
499
|
+
return { ok: false, reply: 'Nothing to propose — the delta is empty.' };
|
|
500
|
+
lines.push('Reply "yes" to apply exactly the above.');
|
|
501
|
+
return { ok: true, echo: lines.join('\n'), patch: validated.patch };
|
|
502
|
+
}
|
|
503
|
+
// ── readout (conversational + /topic status) ─────────────────────────────
|
|
504
|
+
renderReadout(topicKey) {
|
|
505
|
+
const key = String(topicKey);
|
|
506
|
+
const entry = this.deps.store.get(key);
|
|
507
|
+
const resolved = this.deps.resolver.resolve(key);
|
|
508
|
+
const lines = [];
|
|
509
|
+
lines.push(`This topic runs on ${resolved.framework}`
|
|
510
|
+
+ (resolved.model ? ` with model ${resolved.model}` : ' with the account-default model')
|
|
511
|
+
+ (resolved.thinkingMode ? ` and ${resolved.thinkingMode} thinking` : '')
|
|
512
|
+
+ ` (framework: ${resolved.sources.framework}, model: ${resolved.sources.model}).`);
|
|
513
|
+
// §9 framework-aware escalation disclosure.
|
|
514
|
+
if (resolved.escalationOverride === 'suppress') {
|
|
515
|
+
lines.push('Auto-escalation is OFF for this topic — heavy work stays on the pinned baseline.');
|
|
516
|
+
}
|
|
517
|
+
else if (resolved.framework === 'claude-code') {
|
|
518
|
+
lines.push('Heavy work (specs/builds) still auto-escalates to the ultra model here.');
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
lines.push(`Heads up: heavy work in this topic won't auto-escalate while it's on ${resolved.framework} (no escalated model configured for it).`);
|
|
522
|
+
}
|
|
523
|
+
if (entry?.parked) {
|
|
524
|
+
lines.push(`A pin is parked as unhealthy (${entry.parked.reason}) — say "re-apply" to restore it.`);
|
|
525
|
+
}
|
|
526
|
+
if (entry?.intendedProfile) {
|
|
527
|
+
const fields = Object.entries(entry.intendedProfile.fields)
|
|
528
|
+
.filter(([, v]) => v !== undefined)
|
|
529
|
+
.map(([k, v]) => `${k}: ${String(v)}`)
|
|
530
|
+
.join(', ');
|
|
531
|
+
lines.push(`Would-be (dry-run intent, not applied): ${fields}.`);
|
|
532
|
+
}
|
|
533
|
+
return lines.join('\n');
|
|
534
|
+
}
|
|
535
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
536
|
+
authorize(topicKey, principal, origin) {
|
|
537
|
+
const bound = this.deps.boundOperator(topicKey);
|
|
538
|
+
if (principal.kind === 'token') {
|
|
539
|
+
// §10.1: token-trust still refuses writes to topics with no bound operator.
|
|
540
|
+
if (!bound) {
|
|
541
|
+
this.deps.audit({ type: 'write', outcome: 'refused', reason: 'no-bound-operator', topic: topicKey, principal: 'api-token', origin });
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
reply: 'This topic has no bound operator yet — it gets one the first time its operator messages the topic.',
|
|
545
|
+
refusal: { reason: 'no-bound-operator' },
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
if (!bound) {
|
|
551
|
+
this.deps.audit({ type: 'write', outcome: 'refused', reason: 'no-bound-operator', topic: topicKey, principal: `${principal.platform}:${principal.uid}`, origin });
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
reply: 'I couldn\'t determine this topic\'s operator, so I\'m not changing its profile.',
|
|
555
|
+
refusal: { reason: 'no-bound-operator' },
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
if (bound.platform !== principal.platform || bound.uid !== principal.uid) {
|
|
559
|
+
this.deps.audit({
|
|
560
|
+
type: 'write', outcome: 'refused', reason: 'not-bound-operator', topic: topicKey,
|
|
561
|
+
assertedPrincipal: `${principal.platform}:${principal.uid}`, origin,
|
|
562
|
+
});
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
reply: 'Only this topic\'s operator can change its profile.',
|
|
566
|
+
refusal: { reason: 'not-bound-operator' },
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
principalStamp(principal) {
|
|
572
|
+
return principal.kind === 'token' ? 'api-token' : `${principal.platform}:${principal.uid}`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* §5.3 — notify the transfer carrier a local write durably landed (cancels
|
|
576
|
+
* any pending transfer-pull REPLACE for the topic). Only ever called AFTER
|
|
577
|
+
* `await store.mutate(...)` resolved; a flush-refused mutate throws first
|
|
578
|
+
* and cancels nothing (the round-8 rule by construction).
|
|
579
|
+
*/
|
|
580
|
+
notifyLocalWriteDurable(topicKey, origin) {
|
|
581
|
+
try {
|
|
582
|
+
this.deps.onLocalWriteDurable?.(topicKey, origin === 'http' ? 'http' : 'operator');
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
/* @silent-fallback-ok: the §5.3 cancel marker is a post-write amendment to the carrier's pending-pull ledger — a carrier failure must never refuse or roll back a write that already durably landed; the updatedAt backstop still protects the topic at pull-landing time (TOPIC-PROFILE-SPEC §5.3) */
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
profileSnapshot(topicKey) {
|
|
589
|
+
const current = this.deps.store.resolve(topicKey);
|
|
590
|
+
const out = {};
|
|
591
|
+
if (!current)
|
|
592
|
+
return out;
|
|
593
|
+
for (const field of ALL_FIELDS) {
|
|
594
|
+
const v = current[field];
|
|
595
|
+
if (v != null)
|
|
596
|
+
out[field] = v;
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
renderDelta(before, after) {
|
|
601
|
+
const renderSide = (side) => {
|
|
602
|
+
const entries = Object.entries(side);
|
|
603
|
+
if (entries.length === 0)
|
|
604
|
+
return 'defaults';
|
|
605
|
+
return entries.map(([k, v]) => `${k}: ${String(v)}`).join(', ');
|
|
606
|
+
};
|
|
607
|
+
return `Topic profile — was: ${renderSide(before)} → now: ${renderSide(after)}`;
|
|
608
|
+
}
|
|
609
|
+
writeFailure(err, topicKey, principal, origin) {
|
|
610
|
+
if (err instanceof ProfileValidationRefusal) {
|
|
611
|
+
this.deps.audit({
|
|
612
|
+
type: 'write', outcome: 'refused', reason: `validation:${err.validation.failure}`,
|
|
613
|
+
field: err.validation.field, topic: topicKey, principal, origin,
|
|
614
|
+
});
|
|
615
|
+
return { ok: false, reply: `Can't apply that: ${err.validation.reason}. The profile is unchanged.`, refusal: { reason: 'validation', validation: err.validation } };
|
|
616
|
+
}
|
|
617
|
+
if (err instanceof FlushRefusedError) {
|
|
618
|
+
// §5.1: a failed flush REFUSES out loud + already rolled the cache back.
|
|
619
|
+
this.deps.audit({ type: 'write', outcome: 'refused', reason: 'flush-failed', topic: topicKey, principal, origin });
|
|
620
|
+
return { ok: false, reply: 'Couldn\'t save that change durably — nothing was applied. Try again in a moment.', refusal: { reason: 'flush-failed' } };
|
|
621
|
+
}
|
|
622
|
+
if (err instanceof ProfileLockTimeoutError) {
|
|
623
|
+
// §8: WRITE-phase lock timeout is a spoken refusal, never a silent drop.
|
|
624
|
+
this.deps.audit({ type: 'write', outcome: 'refused', reason: 'lock-timeout', topic: topicKey, principal, origin });
|
|
625
|
+
return { ok: false, reply: 'Couldn\'t apply — this topic\'s session is mid-restart; say it again in a minute.', refusal: { reason: 'lock-timeout' } };
|
|
626
|
+
}
|
|
627
|
+
this.deps.audit({ type: 'write', outcome: 'refused', reason: 'internal-error', topic: topicKey, principal, origin });
|
|
628
|
+
return { ok: false, reply: `Couldn't apply that change: ${err instanceof Error ? err.message : String(err)}.`, refusal: { reason: 'internal-error' } };
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=topicProfileWriteSurface.js.map
|