@remembrance-ai/opencode-plugin 0.1.72 → 0.1.74

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/README.md CHANGED
@@ -102,7 +102,9 @@ local MCP server both read `~/.config/remembrance/config.json`, an unset
102
102
  After restarting, run MCP `run_connection_doctor` and require
103
103
  `safe_to_query: true`. It verifies the active connection and gives one exact
104
104
  next step if attention is required. Use `get_connection_status` only for lower-
105
- level fields. Never infer scope from `REMEMBRANCE_API_KEY` alone, from an environment
105
+ level fields. Fresh tool-observer and completion events are informational until
106
+ a later lifecycle opportunity is actually missed. Never infer scope from
107
+ `REMEMBRANCE_API_KEY` alone, from an environment
106
108
  variable, or from an anonymous REST/browser probe — the diagnostic verifies the
107
109
  process that will actually serve opencode's tools, without exposing the key.
108
110
 
@@ -154,7 +156,9 @@ routine organization lesson in memory and stores only an encrypted
154
156
  post-redaction draft. `submit_private_lesson_candidate` is the separate network
155
157
  action; it sends those exact bytes only to the authenticated organization's
156
158
  private verifier queue and can never create or automatically propagate public
157
- content. Drafts never expire or auto-delete.
159
+ content. Unresolved and terminal drafts never expire or auto-delete. Verified
160
+ submissions discard encrypted lesson content immediately; their content-free
161
+ completion markers are automatically deleted after 14 days.
158
162
 
159
163
  The signed policy pins the corrected `private-lesson-redaction-v2` profile and
160
164
  its exact digest. Unsupported-profile drafts become terminal
package/opencode.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "command": [
7
7
  "npx",
8
8
  "-y",
9
- "@remembrance-ai/mcp-server@0.1.72"
9
+ "@remembrance-ai/mcp-server@0.1.74"
10
10
  ],
11
11
  "enabled": true,
12
12
  "environment": {
@@ -16,6 +16,6 @@
16
16
  }
17
17
  },
18
18
  "plugin": [
19
- "@remembrance-ai/opencode-plugin@0.1.72"
19
+ "@remembrance-ai/opencode-plugin@0.1.74"
20
20
  ]
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remembrance-ai/opencode-plugin",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "license": "Apache-2.0",
5
5
  "description": "opencode plugin for Remembrance session hooks, tool observation, and contribution nudges.",
6
6
  "type": "module",
@@ -3124,7 +3124,7 @@ export function privateLessonLifecycleRecoveryInstruction(env = process.env) {
3124
3124
  return [
3125
3125
  `Private lesson lifecycle recovery: ${pending} retained local draft${pending === 1 ? " needs" : "s need"} attention. Silently call inspect_private_lesson_outbox.`,
3126
3126
  "Call submit_private_lesson_candidate for each ready candidate or safety-held draft with pending content-free telemetry. Call retry_private_lesson_candidate only when the candidate or telemetry retry is due.",
3127
- "Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete a draft automatically. Hide routine results.",
3127
+ "Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete an unresolved draft automatically. Verified submissions compact automatically and their content-free markers expire after 14 days. Hide routine results.",
3128
3128
  ].join("\n");
3129
3129
  }
3130
3130
 
@@ -4986,6 +4986,16 @@ function clientUpdateCheck(update) {
4986
4986
  update.guidance.command ?? void 0
4987
4987
  );
4988
4988
  }
4989
+ if (update.status === "current" && compareClientVersions(update.current_version, update.latest_version) > 0) {
4990
+ return warning(
4991
+ "client_update",
4992
+ `This process runs Remembrance ${update.current_version}, ahead of the published ${update.latest_version} manifest for this surface.`,
4993
+ "client_release_manifest_behind",
4994
+ "Do not downgrade this client. Release automation should activate the verified per-surface manifest; querying and contributions may continue.",
4995
+ void 0,
4996
+ true
4997
+ );
4998
+ }
4989
4999
  if (update.status === "current") {
4990
5000
  return pass(
4991
5001
  "client_update",
@@ -5006,8 +5016,9 @@ function clientUpdateSummary(update) {
5006
5016
  };
5007
5017
  }
5008
5018
  if (update.status === "current" || update.status === "update_available") {
5019
+ const aheadOfManifest = update.status === "current" && compareClientVersions(update.current_version, update.latest_version) > 0;
5009
5020
  return {
5010
- status: update.status,
5021
+ status: aheadOfManifest ? "ahead_of_manifest" : update.status,
5011
5022
  current_version: update.current_version,
5012
5023
  latest_version: update.latest_version
5013
5024
  };
@@ -5062,6 +5073,18 @@ function pluginLifecycleCheck(pluginHealth, issues) {
5062
5073
  "The native plugin startup and prompt lifecycle is active."
5063
5074
  );
5064
5075
  }
5076
+ if (pluginHealth.status === "pending") {
5077
+ const pending = Array.isArray(pluginHealth.pending_components) ? pluginHealth.pending_components.filter(
5078
+ (component) => component === "tool_observer" || component === "completion_hook"
5079
+ ) : [];
5080
+ const pendingSummary = pending.map(
5081
+ (component) => component === "tool_observer" ? "tool observation" : "completion"
5082
+ ).join(" and ");
5083
+ return info(
5084
+ "plugin_lifecycle",
5085
+ `The native plugin startup and prompt lifecycle is active; ${pendingSummary || "remaining lifecycle"} verification is pending until ${pending.length === 1 ? "that event has" : "those events have"} an opportunity to run.`
5086
+ );
5087
+ }
5065
5088
  if (stringValue(pluginHealth.surface) === "codex" && issues.includes("hook_trust_required")) {
5066
5089
  const hookTrust = record(pluginHealth.hook_trust);
5067
5090
  const allowedEvents = /* @__PURE__ */ new Set([
@@ -5088,7 +5111,7 @@ function pluginLifecycleCheck(pluginHealth, issues) {
5088
5111
  const codex = stringValue(pluginHealth.surface) === "codex";
5089
5112
  return warning(
5090
5113
  "plugin_lifecycle",
5091
- "The core native lifecycle is active; tool or completion events have not occurred yet.",
5114
+ "The core native lifecycle is active, but a tool-observer or completion event did not run after an observed opportunity.",
5092
5115
  "exercise_plugin_lifecycle",
5093
5116
  codex ? `Use one Remembrance tool, complete one turn, and rerun the doctor. If this warning remains, ${codexHookReviewAction("run_connection_doctor")}` : "Use one Remembrance tool, complete one turn, and rerun the doctor.",
5094
5117
  issues,
@@ -5169,6 +5192,9 @@ function issueCodes(value) {
5169
5192
  function pass(id, summary) {
5170
5193
  return { id, status: "pass", summary, remediation: null };
5171
5194
  }
5195
+ function info(id, summary) {
5196
+ return { id, status: "info", summary, remediation: null };
5197
+ }
5172
5198
  function notApplicable(id, summary) {
5173
5199
  return { id, status: "not_applicable", summary, remediation: null };
5174
5200
  }
@@ -7584,7 +7610,7 @@ var seedSkills = [
7584
7610
  summary: "Operational setup and troubleshooting workflow for Remembrance across Claude Code, Codex, OpenClaw, Cursor, Gemini, MCP, REST, skill-only installs, enterprise keys, and local agent identity.",
7585
7611
  status: "active",
7586
7612
  visibility: "public",
7587
- version: "0.1.23",
7613
+ version: "0.1.24",
7588
7614
  domains: ["agent-skills", "mcp", "resource-discovery"],
7589
7615
  tags: [
7590
7616
  "remembrance",
@@ -7635,7 +7661,7 @@ var seedSkills = [
7635
7661
  "gemini",
7636
7662
  "rest"
7637
7663
  ],
7638
- version: "0.1.23",
7664
+ version: "0.1.24",
7639
7665
  status: "active",
7640
7666
  visibility: "public",
7641
7667
  providers: ["codex", "claude", "cursor", "openclaw", "generic"],
@@ -7998,8 +8024,11 @@ draft remains on the device in \`awaiting_authorization\`; do not retry it
7998
8024
  through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
7999
8025
  responses retry with bounded backoff during later plugin lifecycles. A 401,
8000
8026
  403, policy change, or validation failure remains held for explicit repair.
8001
- Drafts never expire or auto-delete. Inspect, retry, or explicitly delete one
8002
- with the local private-lesson tools; deletion requires confirmation.
8027
+ Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
8028
+ explicitly delete one with the local private-lesson tools; deletion requires
8029
+ confirmation. After a signed submission receipt is verified, the encrypted
8030
+ lesson content is removed immediately and its content-free completion marker
8031
+ is automatically deleted after 14 days.
8003
8032
 
8004
8033
  The signed organization policy pins \`private-lesson-redaction-v2\` and its exact
8005
8034
  supported redactor digest. If either is unsupported, the finalized
@@ -11042,8 +11071,16 @@ function isAlreadyExistsError(error) {
11042
11071
  }
11043
11072
 
11044
11073
  // src/connection-status.ts
11045
- import { existsSync as existsSync2 } from "node:fs";
11046
- import { createHash as createHash2 } from "node:crypto";
11074
+ import {
11075
+ chmodSync,
11076
+ existsSync as existsSync2,
11077
+ lstatSync as lstatSync2,
11078
+ mkdirSync,
11079
+ renameSync,
11080
+ rmSync,
11081
+ writeFileSync
11082
+ } from "node:fs";
11083
+ import { createHash as createHash2, randomBytes } from "node:crypto";
11047
11084
  import { isIP } from "node:net";
11048
11085
  import { homedir as homedir3 } from "node:os";
11049
11086
  import { join as join3 } from "node:path";
@@ -11338,6 +11375,7 @@ var PLUGIN_HEALTH_COMPONENTS = [
11338
11375
  "tool_observer",
11339
11376
  "completion_hook"
11340
11377
  ];
11378
+ var CONNECTION_DOCTOR_PROBE_COMPONENT = "connection_doctor_probe";
11341
11379
  var PLUGIN_HEALTH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
11342
11380
  var PLUGIN_HEALTH_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
11343
11381
  var HOST_POLICY_OBSERVATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -11360,6 +11398,57 @@ var nodeFileSystem2 = {
11360
11398
  mode: secureLocalFileMode,
11361
11399
  list: listSecureLocalDirectory
11362
11400
  };
11401
+ function recordConnectionDoctorLifecycleProbe(options = {}) {
11402
+ const env = options.env ?? process.env;
11403
+ const rawSurface = String(env.REMEMBRANCE_PLUGIN_HOST ?? "").trim().toLowerCase();
11404
+ if (!isPluginHostSurface(rawSurface)) return false;
11405
+ const healthDir = env.REMEMBRANCE_PLUGIN_HEALTH_DIR ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR) : join3(
11406
+ options.homeDirectory ?? homedir3(),
11407
+ ".cache",
11408
+ "remembrance",
11409
+ "plugin-health"
11410
+ );
11411
+ const path = join3(healthDir, `${rawSurface}.json`);
11412
+ if (!existsSync2(path)) return false;
11413
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
11414
+ try {
11415
+ const parsed = JSON.parse(
11416
+ readSecureLocalText(path, {
11417
+ maxBytes: MAX_LOCAL_LIFECYCLE_MARKER_BYTES
11418
+ })
11419
+ );
11420
+ if (!isRecord3(parsed) || parsed.surface !== rawSurface) return false;
11421
+ const components = isRecord3(parsed.components) ? parsed.components : {};
11422
+ if (typeof components.session_start !== "string") return false;
11423
+ mkdirSync(healthDir, { recursive: true, mode: 448 });
11424
+ const directoryMetadata = lstatSync2(healthDir);
11425
+ if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
11426
+ return false;
11427
+ }
11428
+ chmodSync(healthDir, 448);
11429
+ writeFileSync(
11430
+ temporaryPath,
11431
+ `${JSON.stringify({
11432
+ ...parsed,
11433
+ components: {
11434
+ ...components,
11435
+ [CONNECTION_DOCTOR_PROBE_COMPONENT]: (options.now ?? /* @__PURE__ */ new Date()).toISOString()
11436
+ }
11437
+ })}
11438
+ `,
11439
+ { mode: 384, flag: "wx" }
11440
+ );
11441
+ renameSync(temporaryPath, path);
11442
+ chmodSync(path, 384);
11443
+ return true;
11444
+ } catch {
11445
+ try {
11446
+ rmSync(temporaryPath, { force: true });
11447
+ } catch {
11448
+ }
11449
+ return false;
11450
+ }
11451
+ }
11363
11452
  function remembranceConfigPath(env = process.env, homeDirectory = homedir3()) {
11364
11453
  const configRoot = env.REMEMBRANCE_PLUGIN_HOST?.trim().toLowerCase() === "openclaw" ? join3(homeDirectory, ".config") : env.XDG_CONFIG_HOME ?? join3(homeDirectory, ".config");
11365
11454
  return join3(configRoot, "remembrance", "config.json");
@@ -11724,6 +11813,20 @@ function localPluginLifecycleHealth(options) {
11724
11813
  }
11725
11814
  if (Number.isFinite(latestMs)) mergedComponents[component] = latestValue;
11726
11815
  }
11816
+ let latestDoctorProbe;
11817
+ let latestDoctorProbeMs = Number.NEGATIVE_INFINITY;
11818
+ for (const entry of cohort) {
11819
+ const components2 = isRecord3(entry.marker.components) ? entry.marker.components : {};
11820
+ const value = components2[CONNECTION_DOCTOR_PROBE_COMPONENT];
11821
+ const valueMs = validLifecycleTimestamp(value, nowMs);
11822
+ if (valueMs > latestDoctorProbeMs) {
11823
+ latestDoctorProbeMs = valueMs;
11824
+ latestDoctorProbe = value;
11825
+ }
11826
+ }
11827
+ if (Number.isFinite(latestDoctorProbeMs)) {
11828
+ mergedComponents[CONNECTION_DOCTOR_PROBE_COMPONENT] = latestDoctorProbe;
11829
+ }
11727
11830
  const marker = {
11728
11831
  ...newestEntry.marker,
11729
11832
  components: mergedComponents,
@@ -11761,6 +11864,7 @@ function localPluginLifecycleHealth(options) {
11761
11864
  const coreLifecycleObserved = components.session_start.observed && components.prompt_hook.observed;
11762
11865
  const hookTrust = normalizedPluginHookTrust(marker.hook_trust);
11763
11866
  const hookTrustReviewEvents = hookTrust?.status === "review_required" ? new Set(hookTrust.review_events) : /* @__PURE__ */ new Set();
11867
+ const pendingComponents = [];
11764
11868
  if (hookTrustReviewEvents.size > 0) {
11765
11869
  issues.push({
11766
11870
  code: "hook_trust_required",
@@ -11769,17 +11873,40 @@ function localPluginLifecycleHealth(options) {
11769
11873
  ])
11770
11874
  });
11771
11875
  }
11772
- if (coreLifecycleObserved && !components.tool_observer.observed && !hookTrustReviewEvents.has("PostToolUse")) {
11773
- issues.push({
11774
- code: "tool_observer_not_observed",
11775
- action: surface === "codex" ? `Use a Remembrance MCP tool, then call get_connection_status again. If this remains missing, ${codexHookReviewAction("get_connection_status")}` : "No native tool observer has run in this session yet. Use a Remembrance MCP tool, then call get_connection_status again."
11776
- });
11876
+ if (coreLifecycleObserved && !hookTrustReviewEvents.has("PostToolUse")) {
11877
+ const toolObserverMs = validLifecycleTimestamp(
11878
+ componentRecord.tool_observer,
11879
+ nowMs
11880
+ );
11881
+ const missedProbe = Number.isFinite(latestDoctorProbeMs) && (!Number.isFinite(toolObserverMs) || toolObserverMs < latestDoctorProbeMs);
11882
+ if (missedProbe) {
11883
+ issues.push({
11884
+ code: "tool_observer_not_observed",
11885
+ action: surface === "codex" ? `A prior doctor call completed without a later tool-observer event. ${codexHookReviewAction("get_connection_status")}` : "A prior doctor call completed without a later native tool-observer event. Update or reinstall the plugin, fully restart the host, and retry."
11886
+ });
11887
+ } else if (!components.tool_observer.observed) {
11888
+ pendingComponents.push("tool_observer");
11889
+ }
11777
11890
  }
11778
- if (coreLifecycleObserved && !components.completion_hook.observed && !hookTrustReviewEvents.has("Stop")) {
11779
- issues.push({
11780
- code: "completion_hook_not_observed",
11781
- action: surface === "codex" ? `Complete one turn, then call get_connection_status again. If this remains missing, ${codexHookReviewAction("get_connection_status")}` : "No native completion hook has run in this session yet. Complete one turn, then call get_connection_status again."
11782
- });
11891
+ if (coreLifecycleObserved && !hookTrustReviewEvents.has("Stop")) {
11892
+ const promptHookMs = validLifecycleTimestamp(
11893
+ componentRecord.prompt_hook,
11894
+ nowMs
11895
+ );
11896
+ const completionHookMs = validLifecycleTimestamp(
11897
+ componentRecord.completion_hook,
11898
+ nowMs
11899
+ );
11900
+ const completedTurnOpportunity = Number.isFinite(latestDoctorProbeMs) && Number.isFinite(promptHookMs) && promptHookMs > latestDoctorProbeMs;
11901
+ const missedCompletedTurn = completedTurnOpportunity && (!Number.isFinite(completionHookMs) || completionHookMs < latestDoctorProbeMs);
11902
+ if (missedCompletedTurn) {
11903
+ issues.push({
11904
+ code: "completion_hook_not_observed",
11905
+ action: surface === "codex" ? `A new prompt arrived after a prior doctor call without an intervening completion event. ${codexHookReviewAction("get_connection_status")}` : "A new prompt arrived after a prior doctor call without an intervening native completion event. Update or reinstall the plugin, fully restart the host, and retry."
11906
+ });
11907
+ } else if (!components.completion_hook.observed) {
11908
+ pendingComponents.push("completion_hook");
11909
+ }
11783
11910
  }
11784
11911
  const lastSeenMs = validLifecycleTimestamp(marker.last_seen_at, nowMs);
11785
11912
  const lastSeenAt = Number.isFinite(lastSeenMs) ? String(marker.last_seen_at) : null;
@@ -11823,7 +11950,7 @@ function localPluginLifecycleHealth(options) {
11823
11950
  );
11824
11951
  return {
11825
11952
  expected: true,
11826
- status: issues.length === 0 ? "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
11953
+ status: issues.length === 0 ? pendingComponents.length > 0 ? "pending" : "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
11827
11954
  surface,
11828
11955
  plugin_version: markerVersion || null,
11829
11956
  host_version: typeof marker.host_version === "string" && marker.host_version ? marker.host_version : null,
@@ -11836,6 +11963,7 @@ function localPluginLifecycleHealth(options) {
11836
11963
  },
11837
11964
  hook_trust: hookTrust,
11838
11965
  components,
11966
+ pending_components: pendingComponents,
11839
11967
  issues,
11840
11968
  explanation: "Each host process session is tracked independently. Turn-specific prompt, tool, and completion observations are combined only when they share the current SessionStart marker."
11841
11969
  };
@@ -12079,7 +12207,7 @@ async function checkClientUpdate(input) {
12079
12207
  }
12080
12208
 
12081
12209
  // src/private-lesson-outbox.ts
12082
- import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto";
12210
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
12083
12211
  import { AsyncLocalStorage } from "node:async_hooks";
12084
12212
  import {
12085
12213
  chmod as chmod2,
@@ -12095,7 +12223,7 @@ import { homedir as homedir4 } from "node:os";
12095
12223
  import { dirname as dirname2, join as join4 } from "node:path";
12096
12224
 
12097
12225
  // src/private-lesson-keychain.ts
12098
- import { randomBytes } from "node:crypto";
12226
+ import { randomBytes as randomBytes2 } from "node:crypto";
12099
12227
  import { execFileSync } from "node:child_process";
12100
12228
  import { existsSync as existsSync3 } from "node:fs";
12101
12229
  var KEYCHAIN_SERVICE = "dev.remembrance.private-lessons";
@@ -12137,7 +12265,7 @@ function resolvePrivateLessonKeychainKey(env, dependencies = defaultDependencies
12137
12265
  if (!keychainEnabled(env, dependencies)) return null;
12138
12266
  const existing = readPrivateLessonKeychainKey(env, dependencies);
12139
12267
  if (existing) return existing;
12140
- const encoded = dependencies.randomKey ?? randomBytes(32).toString("base64url");
12268
+ const encoded = dependencies.randomKey ?? randomBytes2(32).toString("base64url");
12141
12269
  const generated = decodeKey(encoded);
12142
12270
  if (!generated) return null;
12143
12271
  try {
@@ -12173,9 +12301,11 @@ function decodeKey(encoded) {
12173
12301
  // src/private-lesson-outbox.ts
12174
12302
  var PRIVATE_LESSON_OUTBOX_VERSION = "private-lesson-outbox-v1";
12175
12303
  var PRIVATE_LESSON_OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
12304
+ var PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
12176
12305
  var PRIVATE_LESSON_OUTBOX_MIN_BYTES = 1024 * 1024;
12177
12306
  var PRIVATE_LESSON_OUTBOX_MAX_CONFIGURED_BYTES = 1024 * 1024 * 1024;
12178
12307
  var PRIVATE_LESSON_DRAFT_MAX_BYTES = 64 * 1024;
12308
+ var PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES = 8 * 1024;
12179
12309
  var PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS = 3e4;
12180
12310
  var PRIVATE_LESSON_OUTBOX_LOCK_WAIT_MS = 5e3;
12181
12311
  var privateLessonOutboxLockContext = new AsyncLocalStorage();
@@ -12243,6 +12373,9 @@ async function readPrivateLessonDraft(draftId, options = {}) {
12243
12373
  if (existsSync4(tombstonePath(outbox.directory, draftId))) {
12244
12374
  throw new Error("Private lesson draft was explicitly deleted.");
12245
12375
  }
12376
+ if (existsSync4(submittedRecordPath(outbox.directory, draftId))) {
12377
+ throw new Error("Private lesson draft was already submitted.");
12378
+ }
12246
12379
  const path = draftPath(outbox.directory, draftId);
12247
12380
  await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12248
12381
  const serialized = await readBoundedFile(
@@ -12261,10 +12394,39 @@ async function readPrivateLessonDraft(draftId, options = {}) {
12261
12394
  return reconciled;
12262
12395
  });
12263
12396
  }
12397
+ async function readPrivateLessonSubmittedRecord(draftId, options = {}) {
12398
+ assertDraftId(draftId);
12399
+ const directory = options.outboxDirectory ?? privateLessonOutboxDirectory(options.env, options.homeDirectory);
12400
+ if (!existsSync4(directory)) return null;
12401
+ assertPrivateLessonPathMetadata(await lstat(directory), "directory");
12402
+ return withPrivateLessonOutboxLock(directory, async () => {
12403
+ await reconcileSubmittedRecordsUnlocked(
12404
+ directory,
12405
+ options.now ?? /* @__PURE__ */ new Date()
12406
+ );
12407
+ if (existsSync4(tombstonePath(directory, draftId))) {
12408
+ throw new Error("Private lesson draft was explicitly deleted.");
12409
+ }
12410
+ const path = submittedRecordPath(directory, draftId);
12411
+ if (!existsSync4(path)) return null;
12412
+ await assertRegularPrivateFile(
12413
+ path,
12414
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12415
+ );
12416
+ return parseSubmittedRecord(
12417
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
12418
+ draftId
12419
+ );
12420
+ });
12421
+ }
12264
12422
  async function reconcilePrivateLessonOutboxProfiles(options = {}) {
12265
12423
  const outbox = await readyOutbox(options);
12266
12424
  return withPrivateLessonOutboxLock(outbox.directory, async () => {
12267
12425
  await reconcileExplicitDeletionTombstones(outbox.directory);
12426
+ await reconcileSubmittedRecordsUnlocked(
12427
+ outbox.directory,
12428
+ options.now ?? /* @__PURE__ */ new Date()
12429
+ );
12268
12430
  const entries = await secureOutboxEntries(outbox.directory);
12269
12431
  let inspected = 0;
12270
12432
  let terminalized = 0;
@@ -12285,10 +12447,18 @@ async function reconcilePrivateLessonOutboxProfiles(options = {}) {
12285
12447
  encryption: outbox.encryption
12286
12448
  });
12287
12449
  inspected += 1;
12450
+ if (draft.state === "submitted") {
12451
+ await compactLegacySubmittedDraftUnlocked(draft, outbox);
12452
+ continue;
12453
+ }
12288
12454
  if (before !== "superseded_redactor" && draft.state === "superseded_redactor") {
12289
12455
  terminalized += 1;
12290
12456
  }
12291
12457
  }
12458
+ await reconcileSubmittedRecordsUnlocked(
12459
+ outbox.directory,
12460
+ options.now ?? /* @__PURE__ */ new Date()
12461
+ );
12292
12462
  return { inspected, terminalized };
12293
12463
  });
12294
12464
  }
@@ -12302,8 +12472,22 @@ async function listPrivateLessonDrafts(options = {}) {
12302
12472
  const entries = await secureOutboxEntries(outbox.directory);
12303
12473
  const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
12304
12474
  const drafts = [];
12305
- for (const name of entries.filter((item) => item.endsWith(".lesson.json"))) {
12475
+ for (const name of entries.filter(
12476
+ (item) => item.endsWith(".lesson.json") || item.endsWith(".submitted.json")
12477
+ )) {
12306
12478
  if (drafts.length >= limit) break;
12479
+ if (name.endsWith(".submitted.json")) {
12480
+ const draftId = name.slice(0, -".submitted.json".length);
12481
+ const completion = parseSubmittedRecord(
12482
+ await readBoundedFile(
12483
+ submittedRecordPath(outbox.directory, draftId),
12484
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12485
+ ),
12486
+ draftId
12487
+ );
12488
+ drafts.push(submittedRecordListItem(completion));
12489
+ continue;
12490
+ }
12307
12491
  const draft = await readPrivateLessonDraft(
12308
12492
  name.slice(0, -".lesson.json".length),
12309
12493
  {
@@ -12322,6 +12506,7 @@ async function listPrivateLessonDrafts(options = {}) {
12322
12506
  created_at: draft.created_at,
12323
12507
  updated_at: draft.updated_at,
12324
12508
  submitted_at: draft.submitted_at,
12509
+ completion_expires_at: null,
12325
12510
  terminal_reason: draft.terminal_reason,
12326
12511
  hold_telemetry_status: draft.hold_telemetry_status,
12327
12512
  hold_event_digest: draft.hold_event_digest,
@@ -12349,6 +12534,72 @@ async function updatePrivateLessonDraft(draftId, update, options = {}) {
12349
12534
  return next;
12350
12535
  });
12351
12536
  }
12537
+ async function completePrivateLessonSubmission(draftId, input, options = {}) {
12538
+ assertDraftId(draftId);
12539
+ const outbox = await readyOutbox(options);
12540
+ return withPrivateLessonOutboxLock(outbox.directory, async () => {
12541
+ await reconcileSubmittedRecordsUnlocked(
12542
+ outbox.directory,
12543
+ options.now ?? /* @__PURE__ */ new Date()
12544
+ );
12545
+ if (existsSync4(tombstonePath(outbox.directory, draftId))) {
12546
+ throw new Error("Private lesson draft was explicitly deleted.");
12547
+ }
12548
+ const existingPath = submittedRecordPath(outbox.directory, draftId);
12549
+ if (existsSync4(existingPath)) {
12550
+ const completion2 = parseSubmittedRecord(
12551
+ await readBoundedFile(
12552
+ existingPath,
12553
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12554
+ ),
12555
+ draftId
12556
+ );
12557
+ if (completion2.receipt_digest !== input.receipt_digest) {
12558
+ throw new Error("Private lesson submitted receipt digest conflicts.");
12559
+ }
12560
+ return completion2;
12561
+ }
12562
+ const path = draftPath(outbox.directory, draftId);
12563
+ await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12564
+ const envelope = parseEnvelope(
12565
+ await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
12566
+ draftId
12567
+ );
12568
+ const current = decryptDraft(envelope, outbox.encryption, true);
12569
+ if (current.state === "superseded_redactor") {
12570
+ throw new Error("Terminal private lesson drafts cannot be submitted.");
12571
+ }
12572
+ if (current.state === "submitted" && current.receipt_digest !== input.receipt_digest) {
12573
+ throw new Error("Private lesson submitted receipt digest conflicts.");
12574
+ }
12575
+ const submittedAt = input.submitted_at ?? options.now ?? /* @__PURE__ */ new Date();
12576
+ const submittedIso = submittedAt.toISOString();
12577
+ const draft = current.state === "submitted" ? current : validateDraft({
12578
+ ...current,
12579
+ state: "submitted",
12580
+ attempts: Math.min(1e3, current.attempts + 1),
12581
+ next_retry_at: null,
12582
+ last_error_code: null,
12583
+ submitted_at: submittedIso,
12584
+ receipt_digest: input.receipt_digest,
12585
+ updated_at: submittedIso
12586
+ });
12587
+ const completion = submittedRecordFromDraft(draft, {
12588
+ remote_status: input.remote_status,
12589
+ processing_state: input.processing_state
12590
+ });
12591
+ await atomicWritePrivate(
12592
+ submittedRecordPath(outbox.directory, draftId),
12593
+ `${JSON.stringify(completion)}
12594
+ `
12595
+ );
12596
+ await unlink(path).catch(() => {
12597
+ });
12598
+ await syncDirectory(outbox.directory).catch(() => {
12599
+ });
12600
+ return completion;
12601
+ });
12602
+ }
12352
12603
  async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12353
12604
  if (!confirmed) {
12354
12605
  throw new Error(
@@ -12359,7 +12610,19 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12359
12610
  const outbox = await readyOutbox(options);
12360
12611
  return withPrivateLessonOutboxLock(outbox.directory, async () => {
12361
12612
  const path = draftPath(outbox.directory, draftId);
12362
- await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12613
+ const completionPath = submittedRecordPath(outbox.directory, draftId);
12614
+ if (!existsSync4(path) && !existsSync4(completionPath)) {
12615
+ throw new Error("Private lesson draft does not exist.");
12616
+ }
12617
+ if (existsSync4(path)) {
12618
+ await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12619
+ }
12620
+ if (existsSync4(completionPath)) {
12621
+ await assertRegularPrivateFile(
12622
+ completionPath,
12623
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12624
+ );
12625
+ }
12363
12626
  const tombstone = {
12364
12627
  version: "private-lesson-outbox-deletion-v1",
12365
12628
  draft_id: draftId,
@@ -12371,7 +12634,12 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12371
12634
  `${JSON.stringify(tombstone)}
12372
12635
  `
12373
12636
  );
12374
- await unlink(path);
12637
+ await unlink(path).catch((error) => {
12638
+ if (!isMissingError(error)) throw error;
12639
+ });
12640
+ await unlink(completionPath).catch((error) => {
12641
+ if (!isMissingError(error)) throw error;
12642
+ });
12375
12643
  await syncDirectory(outbox.directory);
12376
12644
  return tombstone;
12377
12645
  });
@@ -12404,6 +12672,15 @@ async function privateLessonOutboxSummary(options = {}) {
12404
12672
  deletionTombstones += 1;
12405
12673
  continue;
12406
12674
  }
12675
+ if (name.endsWith(".submitted.json")) {
12676
+ const draftId = name.slice(0, -".submitted.json".length);
12677
+ parseSubmittedRecord(
12678
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
12679
+ draftId
12680
+ );
12681
+ counts.submitted += 1;
12682
+ continue;
12683
+ }
12407
12684
  if (!name.endsWith(".lesson.json")) continue;
12408
12685
  const envelope = parseEnvelope(
12409
12686
  await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
@@ -12466,6 +12743,11 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
12466
12743
  outboxDirectory: directory,
12467
12744
  encryption
12468
12745
  });
12746
+ } else {
12747
+ await withPrivateLessonOutboxLock(
12748
+ directory,
12749
+ () => reconcileSubmittedRecordsUnlocked(directory, options.now ?? /* @__PURE__ */ new Date())
12750
+ );
12469
12751
  }
12470
12752
  const entries = await secureOutboxEntries(directory);
12471
12753
  const counts = emptyOutboxCounts();
@@ -12481,6 +12763,15 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
12481
12763
  deletionTombstones += 1;
12482
12764
  continue;
12483
12765
  }
12766
+ if (name.endsWith(".submitted.json")) {
12767
+ const draftId = name.slice(0, -".submitted.json".length);
12768
+ parseSubmittedRecord(
12769
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
12770
+ draftId
12771
+ );
12772
+ counts.submitted += 1;
12773
+ continue;
12774
+ }
12484
12775
  if (!name.endsWith(".lesson.json")) continue;
12485
12776
  const envelope = parseEnvelope(
12486
12777
  await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
@@ -12548,7 +12839,7 @@ async function resolvePrivateLessonOutboxEncryption(options = {}) {
12548
12839
  encoded = await readConcurrentPrivateLessonFallbackKey(path);
12549
12840
  } catch (error) {
12550
12841
  if (!isMissingError(error)) throw error;
12551
- encoded = randomBytes2(32).toString("base64url");
12842
+ encoded = randomBytes3(32).toString("base64url");
12552
12843
  try {
12553
12844
  await atomicWritePrivate(path, `${encoded}
12554
12845
  `);
@@ -12680,7 +12971,7 @@ async function withPrivateLessonOutboxLock(directory, operation) {
12680
12971
  if (Date.now() - metadata.mtimeMs > PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS) {
12681
12972
  const stalePath = join4(
12682
12973
  directory,
12683
- `.outbox.lock.stale-${randomBytes2(12).toString("hex")}`
12974
+ `.outbox.lock.stale-${randomBytes3(12).toString("hex")}`
12684
12975
  );
12685
12976
  try {
12686
12977
  await rename(lockPath, stalePath);
@@ -12730,14 +13021,22 @@ async function reconcileExplicitDeletionTombstones(directory) {
12730
13021
  await readBoundedFile(join4(directory, name), 4 * 1024),
12731
13022
  name.slice(0, -".deleted.json".length)
12732
13023
  );
12733
- const retainedDraft = draftPath(directory, tombstone.draft_id);
12734
- if (!existsSync4(retainedDraft)) continue;
12735
- await assertRegularPrivateFile(
12736
- retainedDraft,
12737
- PRIVATE_LESSON_DRAFT_MAX_BYTES
12738
- );
12739
- await unlink(retainedDraft);
12740
- changed = true;
13024
+ const retainedPaths = [
13025
+ {
13026
+ path: draftPath(directory, tombstone.draft_id),
13027
+ maxBytes: PRIVATE_LESSON_DRAFT_MAX_BYTES
13028
+ },
13029
+ {
13030
+ path: submittedRecordPath(directory, tombstone.draft_id),
13031
+ maxBytes: PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
13032
+ }
13033
+ ];
13034
+ for (const retained of retainedPaths) {
13035
+ if (!existsSync4(retained.path)) continue;
13036
+ await assertRegularPrivateFile(retained.path, retained.maxBytes);
13037
+ await unlink(retained.path);
13038
+ changed = true;
13039
+ }
12741
13040
  }
12742
13041
  if (changed) await syncDirectory(directory);
12743
13042
  }
@@ -12768,7 +13067,7 @@ async function writeDraftUnlocked(draft, outbox, replace, _currentOutboxBytes, a
12768
13067
  if (plaintext.length > PRIVATE_LESSON_DRAFT_MAX_BYTES) {
12769
13068
  throw new Error("Private lesson draft exceeds its local size limit.");
12770
13069
  }
12771
- const nonce = randomBytes2(12);
13070
+ const nonce = randomBytes3(12);
12772
13071
  const aad = Buffer.from(
12773
13072
  canonicalJson({
12774
13073
  version: "private-lesson-outbox-envelope-v1",
@@ -12936,6 +13235,135 @@ function terminalizeUnsupportedProfile(draft, now) {
12936
13235
  };
12937
13236
  }
12938
13237
  }
13238
+ function submittedRecordFromDraft(draft, remote) {
13239
+ if (draft.state !== "submitted" || !draft.submitted_at || !draft.receipt_digest) {
13240
+ throw new Error("Private lesson submission completion is incomplete.");
13241
+ }
13242
+ return parseSubmittedRecord(
13243
+ JSON.stringify({
13244
+ version: "private-lesson-outbox-submitted-v1",
13245
+ draft_id: draft.draft_id,
13246
+ state: "submitted",
13247
+ payload_digest: draft.payload_digest,
13248
+ redaction_version: draft.redaction_version,
13249
+ redactor_digest: draft.redactor_digest,
13250
+ attempts: draft.attempts,
13251
+ created_at: draft.created_at,
13252
+ updated_at: draft.updated_at,
13253
+ submitted_at: draft.submitted_at,
13254
+ expires_at: new Date(
13255
+ Date.parse(draft.submitted_at) + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS
13256
+ ).toISOString(),
13257
+ receipt_digest: draft.receipt_digest,
13258
+ remote_status: remote.remote_status,
13259
+ processing_state: remote.processing_state
13260
+ }),
13261
+ draft.draft_id
13262
+ );
13263
+ }
13264
+ async function compactLegacySubmittedDraftUnlocked(draft, outbox) {
13265
+ const completion = submittedRecordFromDraft(draft, {
13266
+ remote_status: null,
13267
+ processing_state: null
13268
+ });
13269
+ const path = submittedRecordPath(outbox.directory, draft.draft_id);
13270
+ try {
13271
+ await atomicWritePrivate(path, `${JSON.stringify(completion)}
13272
+ `);
13273
+ } catch (error) {
13274
+ if (!isAlreadyExistsError2(error)) throw error;
13275
+ const existing = parseSubmittedRecord(
13276
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
13277
+ draft.draft_id
13278
+ );
13279
+ if (existing.payload_digest !== completion.payload_digest || existing.receipt_digest !== completion.receipt_digest) {
13280
+ throw new Error("Private lesson submitted record conflicts.");
13281
+ }
13282
+ }
13283
+ await unlink(draftPath(outbox.directory, draft.draft_id));
13284
+ await syncDirectory(outbox.directory);
13285
+ }
13286
+ async function reconcileSubmittedRecordsUnlocked(directory, now) {
13287
+ const entries = await secureOutboxEntries(directory);
13288
+ let changed = false;
13289
+ for (const name of entries.filter(
13290
+ (entry) => entry.endsWith(".submitted.json")
13291
+ )) {
13292
+ const draftId = name.slice(0, -".submitted.json".length);
13293
+ const path = submittedRecordPath(directory, draftId);
13294
+ const completion = parseSubmittedRecord(
13295
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
13296
+ draftId
13297
+ );
13298
+ const retainedDraft = draftPath(directory, draftId);
13299
+ if (existsSync4(retainedDraft)) {
13300
+ await assertRegularPrivateFile(
13301
+ retainedDraft,
13302
+ PRIVATE_LESSON_DRAFT_MAX_BYTES
13303
+ );
13304
+ await unlink(retainedDraft);
13305
+ changed = true;
13306
+ }
13307
+ if (Date.parse(completion.expires_at) <= now.getTime()) {
13308
+ await unlink(path);
13309
+ changed = true;
13310
+ }
13311
+ }
13312
+ if (changed) await syncDirectory(directory);
13313
+ }
13314
+ function submittedRecordListItem(completion) {
13315
+ return {
13316
+ draft_id: completion.draft_id,
13317
+ state: "submitted",
13318
+ payload_digest: completion.payload_digest,
13319
+ attempts: completion.attempts,
13320
+ next_retry_at: null,
13321
+ last_error_code: null,
13322
+ created_at: completion.created_at,
13323
+ updated_at: completion.updated_at,
13324
+ submitted_at: completion.submitted_at,
13325
+ completion_expires_at: completion.expires_at,
13326
+ terminal_reason: null,
13327
+ hold_telemetry_status: "not_required",
13328
+ hold_event_digest: null,
13329
+ hold_reported_at: null
13330
+ };
13331
+ }
13332
+ function parseSubmittedRecord(serialized, expectedDraftId) {
13333
+ const value = JSON.parse(serialized);
13334
+ const allowedKeys = /* @__PURE__ */ new Set([
13335
+ "version",
13336
+ "draft_id",
13337
+ "state",
13338
+ "payload_digest",
13339
+ "redaction_version",
13340
+ "redactor_digest",
13341
+ "attempts",
13342
+ "created_at",
13343
+ "updated_at",
13344
+ "submitted_at",
13345
+ "expires_at",
13346
+ "receipt_digest",
13347
+ "remote_status",
13348
+ "processing_state"
13349
+ ]);
13350
+ if (!isRecord4(value) || Object.keys(value).some((key) => !allowedKeys.has(key)) || value.version !== "private-lesson-outbox-submitted-v1" || value.draft_id !== expectedDraftId || value.state !== "submitted" || !isSha256Digest(value.payload_digest) || typeof value.redaction_version !== "string" || !isSha256Digest(value.redactor_digest) || !Number.isInteger(value.attempts) || Number(value.attempts) < 1 || Number(value.attempts) > 1e3 || !isIsoTimestamp(value.created_at) || !isIsoTimestamp(value.updated_at) || !isIsoTimestamp(value.submitted_at) || !isIsoTimestamp(value.expires_at) || !isSha256Digest(value.receipt_digest) || value.remote_status !== null && value.remote_status !== "accepted_private_candidate" && value.remote_status !== "duplicate" || value.processing_state !== null && value.processing_state !== "queued_for_verification" && value.processing_state !== "already_queued") {
13351
+ throw new Error("Private lesson submitted record is invalid.");
13352
+ }
13353
+ const submittedAt = Date.parse(value.submitted_at);
13354
+ if (Date.parse(value.created_at) > submittedAt || Date.parse(value.updated_at) < submittedAt || Date.parse(value.expires_at) !== submittedAt + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS || value.remote_status === null !== (value.processing_state === null) || value.remote_status === "accepted_private_candidate" && value.processing_state !== "queued_for_verification" || value.remote_status === "duplicate" && value.processing_state !== "already_queued") {
13355
+ throw new Error("Private lesson submitted record is inconsistent.");
13356
+ }
13357
+ return value;
13358
+ }
13359
+ function isSha256Digest(value) {
13360
+ return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
13361
+ }
13362
+ function isIsoTimestamp(value) {
13363
+ if (typeof value !== "string") return false;
13364
+ const parsed = Date.parse(value);
13365
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
13366
+ }
12939
13367
  function isHoldTelemetryStatus(value) {
12940
13368
  return value === "not_required" || value === "pending" || value === "awaiting_authorization" || value === "retry_scheduled" || value === "submitted" || value === "disabled";
12941
13369
  }
@@ -12983,7 +13411,7 @@ async function atomicReplacePrivate(path, data) {
12983
13411
  await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12984
13412
  const temporary = join4(
12985
13413
  dirname2(path),
12986
- `.${randomBytes2(12).toString("hex")}.tmp`
13414
+ `.${randomBytes3(12).toString("hex")}.tmp`
12987
13415
  );
12988
13416
  try {
12989
13417
  await atomicWritePrivate(temporary, data);
@@ -13045,6 +13473,10 @@ function draftPath(directory, draftId) {
13045
13473
  assertDraftId(draftId);
13046
13474
  return join4(directory, `${draftId}.lesson.json`);
13047
13475
  }
13476
+ function submittedRecordPath(directory, draftId) {
13477
+ assertDraftId(draftId);
13478
+ return join4(directory, `${draftId}.submitted.json`);
13479
+ }
13048
13480
  function tombstonePath(directory, draftId) {
13049
13481
  assertDraftId(draftId);
13050
13482
  return join4(directory, `${draftId}.deleted.json`);
@@ -13097,7 +13529,7 @@ async function readConcurrentPrivateLessonFallbackKey(path) {
13097
13529
  // src/server.ts
13098
13530
  var MAX_REMOTE_RESPONSE_BYTES = 4 * 1024 * 1024;
13099
13531
  var DOCTOR_PROBE_TIMEOUT_MS = 7500;
13100
- var SERVER_VERSION = true ? "0.1.72" : "0.0.0-dev";
13532
+ var SERVER_VERSION = true ? "0.1.74" : "0.0.0-dev";
13101
13533
  var tools = toolDefinitions;
13102
13534
  var doctorCliRequested = process.argv[2] === "doctor";
13103
13535
  var inputBuffer = Buffer.alloc(0);
@@ -13565,6 +13997,13 @@ async function preparePrivateLessonDraft(input) {
13565
13997
  async function inspectPrivateLessonOutbox(input) {
13566
13998
  const summary = await privateLessonOutboxSummary();
13567
13999
  if (input.draft_id) {
14000
+ const completion = await readPrivateLessonSubmittedRecord(input.draft_id);
14001
+ if (completion) {
14002
+ return {
14003
+ draft: privateLessonSubmittedActionResult(completion),
14004
+ outbox: publicOutboxSummary(summary)
14005
+ };
14006
+ }
13568
14007
  const draft = await readPrivateLessonDraft(input.draft_id);
13569
14008
  return {
13570
14009
  draft: privateLessonDraftActionResult(draft),
@@ -13577,6 +14016,12 @@ async function inspectPrivateLessonOutbox(input) {
13577
14016
  };
13578
14017
  }
13579
14018
  async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
14019
+ const completion = await readPrivateLessonSubmittedRecord(draftId);
14020
+ if (completion) {
14021
+ return privateLessonSubmittedActionResult(completion, {
14022
+ status: "already_submitted"
14023
+ });
14024
+ }
13580
14025
  let draft = await readPrivateLessonDraft(draftId);
13581
14026
  if (draft.state === "superseded_redactor") {
13582
14027
  return privateLessonDraftActionResult(draft, {
@@ -13585,7 +14030,18 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13585
14030
  });
13586
14031
  }
13587
14032
  if (draft.state === "submitted") {
13588
- return privateLessonDraftActionResult(draft, {
14033
+ if (!draft.receipt_digest) {
14034
+ throw new PrivateLessonPolicySafetyError(
14035
+ "Submitted private lesson draft is missing its receipt digest."
14036
+ );
14037
+ }
14038
+ const migrated = await completePrivateLessonSubmission(draft.draft_id, {
14039
+ receipt_digest: draft.receipt_digest,
14040
+ remote_status: null,
14041
+ processing_state: null,
14042
+ submitted_at: draft.submitted_at ? new Date(draft.submitted_at) : void 0
14043
+ });
14044
+ return privateLessonSubmittedActionResult(migrated, {
13589
14045
  status: "already_submitted"
13590
14046
  });
13591
14047
  }
@@ -13747,23 +14203,43 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13747
14203
  );
13748
14204
  }
13749
14205
  const receiptDigest = hashValue(canonicalJson(receipt));
13750
- draft = await updatePrivateLessonDraft(draft.draft_id, (current) => ({
13751
- ...current,
14206
+ const completionProcessingState = processingState === "queued_for_verification" || processingState === "already_queued" ? processingState : null;
14207
+ if (!completionProcessingState) {
14208
+ throw new PrivateLessonPolicySafetyError(
14209
+ "Private lesson submission omitted its processing state."
14210
+ );
14211
+ }
14212
+ const completion2 = await completePrivateLessonSubmission(draft.draft_id, {
14213
+ receipt_digest: receiptDigest,
14214
+ remote_status: status,
14215
+ processing_state: completionProcessingState
14216
+ });
14217
+ draft = {
14218
+ ...draft,
13752
14219
  state: "submitted",
13753
- attempts: Math.min(1e3, current.attempts + 1),
14220
+ attempts: completion2.attempts,
13754
14221
  next_retry_at: null,
13755
14222
  last_error_code: null,
13756
- submitted_at: (/* @__PURE__ */ new Date()).toISOString(),
13757
- receipt_digest: receiptDigest,
13758
- policy: receiptPolicy
13759
- }));
14223
+ submitted_at: completion2.submitted_at,
14224
+ receipt_digest: completion2.receipt_digest,
14225
+ policy: receiptPolicy,
14226
+ updated_at: completion2.updated_at
14227
+ };
13760
14228
  return {
13761
- ...privateLessonDraftActionResult(draft, { status }),
14229
+ ...privateLessonDraftActionResult(draft, {
14230
+ status,
14231
+ completion_expires_at: completion2.expires_at
14232
+ }),
13762
14233
  receipt,
13763
14234
  processing_state: processingState
13764
14235
  };
13765
14236
  } catch (error) {
13766
14237
  draft = await persistPrivateLessonFailure(draft, error);
14238
+ if (draft.state === "submitted") {
14239
+ return privateLessonDraftActionResult(draft, {
14240
+ status: "already_submitted"
14241
+ });
14242
+ }
13767
14243
  return privateLessonDraftActionResult(draft, {
13768
14244
  status: "not_submitted",
13769
14245
  action_required: draft.state === "awaiting_authorization" ? "Authorize the exact submit_private_lesson_candidate action, then retry." : null
@@ -14141,13 +14617,37 @@ function privateLessonPolicyBindingConflict(stored, current, allowPolicyRefresh)
14141
14617
  return null;
14142
14618
  }
14143
14619
  async function persistPrivateLessonFailure(draft, error, explicitCode) {
14144
- return updatePrivateLessonDraft(
14145
- draft.draft_id,
14146
- (current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
14147
- ...current,
14148
- ...privateLessonFailureTransition(current, error, explicitCode)
14620
+ const completed = await readPrivateLessonSubmittedRecord(draft.draft_id);
14621
+ if (completed) return submittedDraftFromCompletion(draft, completed);
14622
+ try {
14623
+ return await updatePrivateLessonDraft(
14624
+ draft.draft_id,
14625
+ (current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
14626
+ ...current,
14627
+ ...privateLessonFailureTransition(current, error, explicitCode)
14628
+ }
14629
+ );
14630
+ } catch (updateError) {
14631
+ const racedCompletion = await readPrivateLessonSubmittedRecord(
14632
+ draft.draft_id
14633
+ );
14634
+ if (racedCompletion) {
14635
+ return submittedDraftFromCompletion(draft, racedCompletion);
14149
14636
  }
14150
- );
14637
+ throw updateError;
14638
+ }
14639
+ }
14640
+ function submittedDraftFromCompletion(draft, completion) {
14641
+ return {
14642
+ ...draft,
14643
+ state: "submitted",
14644
+ attempts: completion.attempts,
14645
+ next_retry_at: null,
14646
+ last_error_code: null,
14647
+ submitted_at: completion.submitted_at,
14648
+ receipt_digest: completion.receipt_digest,
14649
+ updated_at: completion.updated_at
14650
+ };
14151
14651
  }
14152
14652
  async function persistPrivateLessonHoldFailure(draft, error, explicitCode) {
14153
14653
  return updatePrivateLessonDraft(draft.draft_id, (current) => {
@@ -14239,6 +14739,29 @@ function privateLessonDraftActionResult(draft, extra = {}) {
14239
14739
  ...extra
14240
14740
  };
14241
14741
  }
14742
+ function privateLessonSubmittedActionResult(completion, extra = {}) {
14743
+ return {
14744
+ draft_id: completion.draft_id,
14745
+ state: "submitted",
14746
+ payload_digest: completion.payload_digest,
14747
+ redaction_version: completion.redaction_version,
14748
+ redactor_digest: completion.redactor_digest,
14749
+ redaction_counts: {},
14750
+ held_reasons: [],
14751
+ attempts: completion.attempts,
14752
+ next_retry_at: null,
14753
+ last_error_code: null,
14754
+ terminal_reason: null,
14755
+ hold_telemetry_status: "not_required",
14756
+ hold_event_digest: null,
14757
+ hold_receipt_digest: null,
14758
+ hold_reported_at: null,
14759
+ submitted_at: completion.submitted_at,
14760
+ completion_expires_at: completion.expires_at,
14761
+ retained_locally: false,
14762
+ ...extra
14763
+ };
14764
+ }
14242
14765
  function publicOutboxSummary(summary) {
14243
14766
  const { directory: _directory, ...safe } = summary;
14244
14767
  return safe;
@@ -14781,6 +15304,9 @@ async function runLocalConnectionDoctor(options, execution = "mcp_tool", access
14781
15304
  policyResult,
14782
15305
  outboxResult
14783
15306
  });
15307
+ if (execution === "mcp_tool") {
15308
+ recordConnectionDoctorLifecycleProbe();
15309
+ }
14784
15310
  return {
14785
15311
  ...report,
14786
15312
  status: report.status === "healthy" && privateLessonAutomation.status !== "ready" && privateLessonAutomation.status !== "not_applicable" ? "attention" : report.status,
@@ -22,8 +22,8 @@ background and register a privacy-bounded runtime profile. They never transmit
22
22
  the hostname, username, config path, or repository path. Follow the returned
23
23
  machine-readable `next_action` rather than asking the user to invent an
24
24
  identity. Tool-observer and completion timestamps appear after those events
25
- become eligible; their absence during the first prompt is not, by itself, a
26
- failure.
25
+ become eligible. A fresh doctor reports them as informational; only a later
26
+ missed lifecycle opportunity raises an attention warning.
27
27
 
28
28
  If this skill is visible but `run_connection_doctor` is absent, explicitly tell
29
29
  the user that Remembrance is only partially active. Do not silently fall back
@@ -176,8 +176,10 @@ nothing was sent and the encrypted draft remains local in
176
176
  `awaiting_authorization`; do not retry it through REST or another transport.
177
177
  Transient timeouts, 429s, and 5xx responses remain queued for bounded retry on a
178
178
  later lifecycle. Authentication, policy, or validation failures remain held.
179
- Queued lessons never expire or auto-delete; deleting one requires an explicit
180
- confirmed local action.
179
+ Unresolved and terminal lessons never expire or auto-delete; deleting one
180
+ requires an explicit confirmed local action. After a signed submission receipt
181
+ is verified, the encrypted lesson content is removed immediately and its
182
+ content-free completion marker is automatically deleted after 14 days.
181
183
 
182
184
  The signed policy pins `private-lesson-redaction-v2` and the exact digest of its
183
185
  current rules. An unsupported version or digest moves the encrypted draft to
@@ -292,8 +292,11 @@ draft remains on the device in `awaiting_authorization`; do not retry it
292
292
  through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
293
293
  responses retry with bounded backoff during later plugin lifecycles. A 401,
294
294
  403, policy change, or validation failure remains held for explicit repair.
295
- Drafts never expire or auto-delete. Inspect, retry, or explicitly delete one
296
- with the local private-lesson tools; deletion requires confirmation.
295
+ Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
296
+ explicitly delete one with the local private-lesson tools; deletion requires
297
+ confirmation. After a signed submission receipt is verified, the encrypted
298
+ lesson content is removed immediately and its content-free completion marker
299
+ is automatically deleted after 14 days.
297
300
 
298
301
  The signed organization policy pins `private-lesson-redaction-v2` and its exact
299
302
  supported redactor digest. If either is unsupported, the finalized