@remembrance-ai/opencode-plugin 0.1.73 → 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.73"
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.73"
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.73",
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
 
@@ -5073,6 +5073,18 @@ function pluginLifecycleCheck(pluginHealth, issues) {
5073
5073
  "The native plugin startup and prompt lifecycle is active."
5074
5074
  );
5075
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
+ }
5076
5088
  if (stringValue(pluginHealth.surface) === "codex" && issues.includes("hook_trust_required")) {
5077
5089
  const hookTrust = record(pluginHealth.hook_trust);
5078
5090
  const allowedEvents = /* @__PURE__ */ new Set([
@@ -5099,7 +5111,7 @@ function pluginLifecycleCheck(pluginHealth, issues) {
5099
5111
  const codex = stringValue(pluginHealth.surface) === "codex";
5100
5112
  return warning(
5101
5113
  "plugin_lifecycle",
5102
- "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.",
5103
5115
  "exercise_plugin_lifecycle",
5104
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.",
5105
5117
  issues,
@@ -5180,6 +5192,9 @@ function issueCodes(value) {
5180
5192
  function pass(id, summary) {
5181
5193
  return { id, status: "pass", summary, remediation: null };
5182
5194
  }
5195
+ function info(id, summary) {
5196
+ return { id, status: "info", summary, remediation: null };
5197
+ }
5183
5198
  function notApplicable(id, summary) {
5184
5199
  return { id, status: "not_applicable", summary, remediation: null };
5185
5200
  }
@@ -7595,7 +7610,7 @@ var seedSkills = [
7595
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.",
7596
7611
  status: "active",
7597
7612
  visibility: "public",
7598
- version: "0.1.23",
7613
+ version: "0.1.24",
7599
7614
  domains: ["agent-skills", "mcp", "resource-discovery"],
7600
7615
  tags: [
7601
7616
  "remembrance",
@@ -7646,7 +7661,7 @@ var seedSkills = [
7646
7661
  "gemini",
7647
7662
  "rest"
7648
7663
  ],
7649
- version: "0.1.23",
7664
+ version: "0.1.24",
7650
7665
  status: "active",
7651
7666
  visibility: "public",
7652
7667
  providers: ["codex", "claude", "cursor", "openclaw", "generic"],
@@ -8009,8 +8024,11 @@ draft remains on the device in \`awaiting_authorization\`; do not retry it
8009
8024
  through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
8010
8025
  responses retry with bounded backoff during later plugin lifecycles. A 401,
8011
8026
  403, policy change, or validation failure remains held for explicit repair.
8012
- Drafts never expire or auto-delete. Inspect, retry, or explicitly delete one
8013
- 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.
8014
8032
 
8015
8033
  The signed organization policy pins \`private-lesson-redaction-v2\` and its exact
8016
8034
  supported redactor digest. If either is unsupported, the finalized
@@ -11053,8 +11071,16 @@ function isAlreadyExistsError(error) {
11053
11071
  }
11054
11072
 
11055
11073
  // src/connection-status.ts
11056
- import { existsSync as existsSync2 } from "node:fs";
11057
- 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";
11058
11084
  import { isIP } from "node:net";
11059
11085
  import { homedir as homedir3 } from "node:os";
11060
11086
  import { join as join3 } from "node:path";
@@ -11349,6 +11375,7 @@ var PLUGIN_HEALTH_COMPONENTS = [
11349
11375
  "tool_observer",
11350
11376
  "completion_hook"
11351
11377
  ];
11378
+ var CONNECTION_DOCTOR_PROBE_COMPONENT = "connection_doctor_probe";
11352
11379
  var PLUGIN_HEALTH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
11353
11380
  var PLUGIN_HEALTH_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
11354
11381
  var HOST_POLICY_OBSERVATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -11371,6 +11398,57 @@ var nodeFileSystem2 = {
11371
11398
  mode: secureLocalFileMode,
11372
11399
  list: listSecureLocalDirectory
11373
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
+ }
11374
11452
  function remembranceConfigPath(env = process.env, homeDirectory = homedir3()) {
11375
11453
  const configRoot = env.REMEMBRANCE_PLUGIN_HOST?.trim().toLowerCase() === "openclaw" ? join3(homeDirectory, ".config") : env.XDG_CONFIG_HOME ?? join3(homeDirectory, ".config");
11376
11454
  return join3(configRoot, "remembrance", "config.json");
@@ -11735,6 +11813,20 @@ function localPluginLifecycleHealth(options) {
11735
11813
  }
11736
11814
  if (Number.isFinite(latestMs)) mergedComponents[component] = latestValue;
11737
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
+ }
11738
11830
  const marker = {
11739
11831
  ...newestEntry.marker,
11740
11832
  components: mergedComponents,
@@ -11772,6 +11864,7 @@ function localPluginLifecycleHealth(options) {
11772
11864
  const coreLifecycleObserved = components.session_start.observed && components.prompt_hook.observed;
11773
11865
  const hookTrust = normalizedPluginHookTrust(marker.hook_trust);
11774
11866
  const hookTrustReviewEvents = hookTrust?.status === "review_required" ? new Set(hookTrust.review_events) : /* @__PURE__ */ new Set();
11867
+ const pendingComponents = [];
11775
11868
  if (hookTrustReviewEvents.size > 0) {
11776
11869
  issues.push({
11777
11870
  code: "hook_trust_required",
@@ -11780,17 +11873,40 @@ function localPluginLifecycleHealth(options) {
11780
11873
  ])
11781
11874
  });
11782
11875
  }
11783
- if (coreLifecycleObserved && !components.tool_observer.observed && !hookTrustReviewEvents.has("PostToolUse")) {
11784
- issues.push({
11785
- code: "tool_observer_not_observed",
11786
- 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."
11787
- });
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
+ }
11788
11890
  }
11789
- if (coreLifecycleObserved && !components.completion_hook.observed && !hookTrustReviewEvents.has("Stop")) {
11790
- issues.push({
11791
- code: "completion_hook_not_observed",
11792
- 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."
11793
- });
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
+ }
11794
11910
  }
11795
11911
  const lastSeenMs = validLifecycleTimestamp(marker.last_seen_at, nowMs);
11796
11912
  const lastSeenAt = Number.isFinite(lastSeenMs) ? String(marker.last_seen_at) : null;
@@ -11834,7 +11950,7 @@ function localPluginLifecycleHealth(options) {
11834
11950
  );
11835
11951
  return {
11836
11952
  expected: true,
11837
- status: issues.length === 0 ? "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
11953
+ status: issues.length === 0 ? pendingComponents.length > 0 ? "pending" : "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
11838
11954
  surface,
11839
11955
  plugin_version: markerVersion || null,
11840
11956
  host_version: typeof marker.host_version === "string" && marker.host_version ? marker.host_version : null,
@@ -11847,6 +11963,7 @@ function localPluginLifecycleHealth(options) {
11847
11963
  },
11848
11964
  hook_trust: hookTrust,
11849
11965
  components,
11966
+ pending_components: pendingComponents,
11850
11967
  issues,
11851
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."
11852
11969
  };
@@ -12090,7 +12207,7 @@ async function checkClientUpdate(input) {
12090
12207
  }
12091
12208
 
12092
12209
  // src/private-lesson-outbox.ts
12093
- import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto";
12210
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
12094
12211
  import { AsyncLocalStorage } from "node:async_hooks";
12095
12212
  import {
12096
12213
  chmod as chmod2,
@@ -12106,7 +12223,7 @@ import { homedir as homedir4 } from "node:os";
12106
12223
  import { dirname as dirname2, join as join4 } from "node:path";
12107
12224
 
12108
12225
  // src/private-lesson-keychain.ts
12109
- import { randomBytes } from "node:crypto";
12226
+ import { randomBytes as randomBytes2 } from "node:crypto";
12110
12227
  import { execFileSync } from "node:child_process";
12111
12228
  import { existsSync as existsSync3 } from "node:fs";
12112
12229
  var KEYCHAIN_SERVICE = "dev.remembrance.private-lessons";
@@ -12148,7 +12265,7 @@ function resolvePrivateLessonKeychainKey(env, dependencies = defaultDependencies
12148
12265
  if (!keychainEnabled(env, dependencies)) return null;
12149
12266
  const existing = readPrivateLessonKeychainKey(env, dependencies);
12150
12267
  if (existing) return existing;
12151
- const encoded = dependencies.randomKey ?? randomBytes(32).toString("base64url");
12268
+ const encoded = dependencies.randomKey ?? randomBytes2(32).toString("base64url");
12152
12269
  const generated = decodeKey(encoded);
12153
12270
  if (!generated) return null;
12154
12271
  try {
@@ -12184,9 +12301,11 @@ function decodeKey(encoded) {
12184
12301
  // src/private-lesson-outbox.ts
12185
12302
  var PRIVATE_LESSON_OUTBOX_VERSION = "private-lesson-outbox-v1";
12186
12303
  var PRIVATE_LESSON_OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
12304
+ var PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
12187
12305
  var PRIVATE_LESSON_OUTBOX_MIN_BYTES = 1024 * 1024;
12188
12306
  var PRIVATE_LESSON_OUTBOX_MAX_CONFIGURED_BYTES = 1024 * 1024 * 1024;
12189
12307
  var PRIVATE_LESSON_DRAFT_MAX_BYTES = 64 * 1024;
12308
+ var PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES = 8 * 1024;
12190
12309
  var PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS = 3e4;
12191
12310
  var PRIVATE_LESSON_OUTBOX_LOCK_WAIT_MS = 5e3;
12192
12311
  var privateLessonOutboxLockContext = new AsyncLocalStorage();
@@ -12254,6 +12373,9 @@ async function readPrivateLessonDraft(draftId, options = {}) {
12254
12373
  if (existsSync4(tombstonePath(outbox.directory, draftId))) {
12255
12374
  throw new Error("Private lesson draft was explicitly deleted.");
12256
12375
  }
12376
+ if (existsSync4(submittedRecordPath(outbox.directory, draftId))) {
12377
+ throw new Error("Private lesson draft was already submitted.");
12378
+ }
12257
12379
  const path = draftPath(outbox.directory, draftId);
12258
12380
  await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12259
12381
  const serialized = await readBoundedFile(
@@ -12272,10 +12394,39 @@ async function readPrivateLessonDraft(draftId, options = {}) {
12272
12394
  return reconciled;
12273
12395
  });
12274
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
+ }
12275
12422
  async function reconcilePrivateLessonOutboxProfiles(options = {}) {
12276
12423
  const outbox = await readyOutbox(options);
12277
12424
  return withPrivateLessonOutboxLock(outbox.directory, async () => {
12278
12425
  await reconcileExplicitDeletionTombstones(outbox.directory);
12426
+ await reconcileSubmittedRecordsUnlocked(
12427
+ outbox.directory,
12428
+ options.now ?? /* @__PURE__ */ new Date()
12429
+ );
12279
12430
  const entries = await secureOutboxEntries(outbox.directory);
12280
12431
  let inspected = 0;
12281
12432
  let terminalized = 0;
@@ -12296,10 +12447,18 @@ async function reconcilePrivateLessonOutboxProfiles(options = {}) {
12296
12447
  encryption: outbox.encryption
12297
12448
  });
12298
12449
  inspected += 1;
12450
+ if (draft.state === "submitted") {
12451
+ await compactLegacySubmittedDraftUnlocked(draft, outbox);
12452
+ continue;
12453
+ }
12299
12454
  if (before !== "superseded_redactor" && draft.state === "superseded_redactor") {
12300
12455
  terminalized += 1;
12301
12456
  }
12302
12457
  }
12458
+ await reconcileSubmittedRecordsUnlocked(
12459
+ outbox.directory,
12460
+ options.now ?? /* @__PURE__ */ new Date()
12461
+ );
12303
12462
  return { inspected, terminalized };
12304
12463
  });
12305
12464
  }
@@ -12313,8 +12472,22 @@ async function listPrivateLessonDrafts(options = {}) {
12313
12472
  const entries = await secureOutboxEntries(outbox.directory);
12314
12473
  const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
12315
12474
  const drafts = [];
12316
- 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
+ )) {
12317
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
+ }
12318
12491
  const draft = await readPrivateLessonDraft(
12319
12492
  name.slice(0, -".lesson.json".length),
12320
12493
  {
@@ -12333,6 +12506,7 @@ async function listPrivateLessonDrafts(options = {}) {
12333
12506
  created_at: draft.created_at,
12334
12507
  updated_at: draft.updated_at,
12335
12508
  submitted_at: draft.submitted_at,
12509
+ completion_expires_at: null,
12336
12510
  terminal_reason: draft.terminal_reason,
12337
12511
  hold_telemetry_status: draft.hold_telemetry_status,
12338
12512
  hold_event_digest: draft.hold_event_digest,
@@ -12360,6 +12534,72 @@ async function updatePrivateLessonDraft(draftId, update, options = {}) {
12360
12534
  return next;
12361
12535
  });
12362
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
+ }
12363
12603
  async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12364
12604
  if (!confirmed) {
12365
12605
  throw new Error(
@@ -12370,7 +12610,19 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12370
12610
  const outbox = await readyOutbox(options);
12371
12611
  return withPrivateLessonOutboxLock(outbox.directory, async () => {
12372
12612
  const path = draftPath(outbox.directory, draftId);
12373
- 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
+ }
12374
12626
  const tombstone = {
12375
12627
  version: "private-lesson-outbox-deletion-v1",
12376
12628
  draft_id: draftId,
@@ -12382,7 +12634,12 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12382
12634
  `${JSON.stringify(tombstone)}
12383
12635
  `
12384
12636
  );
12385
- 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
+ });
12386
12643
  await syncDirectory(outbox.directory);
12387
12644
  return tombstone;
12388
12645
  });
@@ -12415,6 +12672,15 @@ async function privateLessonOutboxSummary(options = {}) {
12415
12672
  deletionTombstones += 1;
12416
12673
  continue;
12417
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
+ }
12418
12684
  if (!name.endsWith(".lesson.json")) continue;
12419
12685
  const envelope = parseEnvelope(
12420
12686
  await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
@@ -12477,6 +12743,11 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
12477
12743
  outboxDirectory: directory,
12478
12744
  encryption
12479
12745
  });
12746
+ } else {
12747
+ await withPrivateLessonOutboxLock(
12748
+ directory,
12749
+ () => reconcileSubmittedRecordsUnlocked(directory, options.now ?? /* @__PURE__ */ new Date())
12750
+ );
12480
12751
  }
12481
12752
  const entries = await secureOutboxEntries(directory);
12482
12753
  const counts = emptyOutboxCounts();
@@ -12492,6 +12763,15 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
12492
12763
  deletionTombstones += 1;
12493
12764
  continue;
12494
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
+ }
12495
12775
  if (!name.endsWith(".lesson.json")) continue;
12496
12776
  const envelope = parseEnvelope(
12497
12777
  await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
@@ -12559,7 +12839,7 @@ async function resolvePrivateLessonOutboxEncryption(options = {}) {
12559
12839
  encoded = await readConcurrentPrivateLessonFallbackKey(path);
12560
12840
  } catch (error) {
12561
12841
  if (!isMissingError(error)) throw error;
12562
- encoded = randomBytes2(32).toString("base64url");
12842
+ encoded = randomBytes3(32).toString("base64url");
12563
12843
  try {
12564
12844
  await atomicWritePrivate(path, `${encoded}
12565
12845
  `);
@@ -12691,7 +12971,7 @@ async function withPrivateLessonOutboxLock(directory, operation) {
12691
12971
  if (Date.now() - metadata.mtimeMs > PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS) {
12692
12972
  const stalePath = join4(
12693
12973
  directory,
12694
- `.outbox.lock.stale-${randomBytes2(12).toString("hex")}`
12974
+ `.outbox.lock.stale-${randomBytes3(12).toString("hex")}`
12695
12975
  );
12696
12976
  try {
12697
12977
  await rename(lockPath, stalePath);
@@ -12741,14 +13021,22 @@ async function reconcileExplicitDeletionTombstones(directory) {
12741
13021
  await readBoundedFile(join4(directory, name), 4 * 1024),
12742
13022
  name.slice(0, -".deleted.json".length)
12743
13023
  );
12744
- const retainedDraft = draftPath(directory, tombstone.draft_id);
12745
- if (!existsSync4(retainedDraft)) continue;
12746
- await assertRegularPrivateFile(
12747
- retainedDraft,
12748
- PRIVATE_LESSON_DRAFT_MAX_BYTES
12749
- );
12750
- await unlink(retainedDraft);
12751
- 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
+ }
12752
13040
  }
12753
13041
  if (changed) await syncDirectory(directory);
12754
13042
  }
@@ -12779,7 +13067,7 @@ async function writeDraftUnlocked(draft, outbox, replace, _currentOutboxBytes, a
12779
13067
  if (plaintext.length > PRIVATE_LESSON_DRAFT_MAX_BYTES) {
12780
13068
  throw new Error("Private lesson draft exceeds its local size limit.");
12781
13069
  }
12782
- const nonce = randomBytes2(12);
13070
+ const nonce = randomBytes3(12);
12783
13071
  const aad = Buffer.from(
12784
13072
  canonicalJson({
12785
13073
  version: "private-lesson-outbox-envelope-v1",
@@ -12947,6 +13235,135 @@ function terminalizeUnsupportedProfile(draft, now) {
12947
13235
  };
12948
13236
  }
12949
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
+ }
12950
13367
  function isHoldTelemetryStatus(value) {
12951
13368
  return value === "not_required" || value === "pending" || value === "awaiting_authorization" || value === "retry_scheduled" || value === "submitted" || value === "disabled";
12952
13369
  }
@@ -12994,7 +13411,7 @@ async function atomicReplacePrivate(path, data) {
12994
13411
  await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12995
13412
  const temporary = join4(
12996
13413
  dirname2(path),
12997
- `.${randomBytes2(12).toString("hex")}.tmp`
13414
+ `.${randomBytes3(12).toString("hex")}.tmp`
12998
13415
  );
12999
13416
  try {
13000
13417
  await atomicWritePrivate(temporary, data);
@@ -13056,6 +13473,10 @@ function draftPath(directory, draftId) {
13056
13473
  assertDraftId(draftId);
13057
13474
  return join4(directory, `${draftId}.lesson.json`);
13058
13475
  }
13476
+ function submittedRecordPath(directory, draftId) {
13477
+ assertDraftId(draftId);
13478
+ return join4(directory, `${draftId}.submitted.json`);
13479
+ }
13059
13480
  function tombstonePath(directory, draftId) {
13060
13481
  assertDraftId(draftId);
13061
13482
  return join4(directory, `${draftId}.deleted.json`);
@@ -13108,7 +13529,7 @@ async function readConcurrentPrivateLessonFallbackKey(path) {
13108
13529
  // src/server.ts
13109
13530
  var MAX_REMOTE_RESPONSE_BYTES = 4 * 1024 * 1024;
13110
13531
  var DOCTOR_PROBE_TIMEOUT_MS = 7500;
13111
- var SERVER_VERSION = true ? "0.1.73" : "0.0.0-dev";
13532
+ var SERVER_VERSION = true ? "0.1.74" : "0.0.0-dev";
13112
13533
  var tools = toolDefinitions;
13113
13534
  var doctorCliRequested = process.argv[2] === "doctor";
13114
13535
  var inputBuffer = Buffer.alloc(0);
@@ -13576,6 +13997,13 @@ async function preparePrivateLessonDraft(input) {
13576
13997
  async function inspectPrivateLessonOutbox(input) {
13577
13998
  const summary = await privateLessonOutboxSummary();
13578
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
+ }
13579
14007
  const draft = await readPrivateLessonDraft(input.draft_id);
13580
14008
  return {
13581
14009
  draft: privateLessonDraftActionResult(draft),
@@ -13588,6 +14016,12 @@ async function inspectPrivateLessonOutbox(input) {
13588
14016
  };
13589
14017
  }
13590
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
+ }
13591
14025
  let draft = await readPrivateLessonDraft(draftId);
13592
14026
  if (draft.state === "superseded_redactor") {
13593
14027
  return privateLessonDraftActionResult(draft, {
@@ -13596,7 +14030,18 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13596
14030
  });
13597
14031
  }
13598
14032
  if (draft.state === "submitted") {
13599
- 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, {
13600
14045
  status: "already_submitted"
13601
14046
  });
13602
14047
  }
@@ -13758,23 +14203,43 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13758
14203
  );
13759
14204
  }
13760
14205
  const receiptDigest = hashValue(canonicalJson(receipt));
13761
- draft = await updatePrivateLessonDraft(draft.draft_id, (current) => ({
13762
- ...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,
13763
14219
  state: "submitted",
13764
- attempts: Math.min(1e3, current.attempts + 1),
14220
+ attempts: completion2.attempts,
13765
14221
  next_retry_at: null,
13766
14222
  last_error_code: null,
13767
- submitted_at: (/* @__PURE__ */ new Date()).toISOString(),
13768
- receipt_digest: receiptDigest,
13769
- policy: receiptPolicy
13770
- }));
14223
+ submitted_at: completion2.submitted_at,
14224
+ receipt_digest: completion2.receipt_digest,
14225
+ policy: receiptPolicy,
14226
+ updated_at: completion2.updated_at
14227
+ };
13771
14228
  return {
13772
- ...privateLessonDraftActionResult(draft, { status }),
14229
+ ...privateLessonDraftActionResult(draft, {
14230
+ status,
14231
+ completion_expires_at: completion2.expires_at
14232
+ }),
13773
14233
  receipt,
13774
14234
  processing_state: processingState
13775
14235
  };
13776
14236
  } catch (error) {
13777
14237
  draft = await persistPrivateLessonFailure(draft, error);
14238
+ if (draft.state === "submitted") {
14239
+ return privateLessonDraftActionResult(draft, {
14240
+ status: "already_submitted"
14241
+ });
14242
+ }
13778
14243
  return privateLessonDraftActionResult(draft, {
13779
14244
  status: "not_submitted",
13780
14245
  action_required: draft.state === "awaiting_authorization" ? "Authorize the exact submit_private_lesson_candidate action, then retry." : null
@@ -14152,13 +14617,37 @@ function privateLessonPolicyBindingConflict(stored, current, allowPolicyRefresh)
14152
14617
  return null;
14153
14618
  }
14154
14619
  async function persistPrivateLessonFailure(draft, error, explicitCode) {
14155
- return updatePrivateLessonDraft(
14156
- draft.draft_id,
14157
- (current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
14158
- ...current,
14159
- ...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);
14160
14636
  }
14161
- );
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
+ };
14162
14651
  }
14163
14652
  async function persistPrivateLessonHoldFailure(draft, error, explicitCode) {
14164
14653
  return updatePrivateLessonDraft(draft.draft_id, (current) => {
@@ -14250,6 +14739,29 @@ function privateLessonDraftActionResult(draft, extra = {}) {
14250
14739
  ...extra
14251
14740
  };
14252
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
+ }
14253
14765
  function publicOutboxSummary(summary) {
14254
14766
  const { directory: _directory, ...safe } = summary;
14255
14767
  return safe;
@@ -14792,6 +15304,9 @@ async function runLocalConnectionDoctor(options, execution = "mcp_tool", access
14792
15304
  policyResult,
14793
15305
  outboxResult
14794
15306
  });
15307
+ if (execution === "mcp_tool") {
15308
+ recordConnectionDoctorLifecycleProbe();
15309
+ }
14795
15310
  return {
14796
15311
  ...report,
14797
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