@unson/brainbase-mcp 0.22.3 → 0.22.5

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.
@@ -1,4 +1,5 @@
1
1
  import { type FoundationHttpCsrfVerifier, type FoundationHttpRouter } from './foundation-http.js';
2
+ import type { ObjectiveDefinition } from './ontology-foundation.js';
2
3
  /**
3
4
  * Readiness probe for the Graph history contract used by the public adapter.
4
5
  *
@@ -31,8 +32,20 @@ export interface FoundationGraphHttpOptions {
31
32
  readonly withAccessContext: <T>(identity: FoundationGraphTrustedIdentity, callback: (client: FoundationGraphQueryClient) => Promise<T>) => Promise<T>;
32
33
  /** Host-owned CSRF verification port for mutation requests. */
33
34
  readonly csrf?: FoundationHttpCsrfVerifier;
35
+ /** Canonical host writer, using this request's authenticated transaction.
36
+ * Must enforce current ACL, tenant/project access and expectedRevision CAS;
37
+ * null means create-only. Never connect a low-level or maintenance writer.
38
+ */
39
+ readonly writeObjectiveDraft?: (input: FoundationGraphObjectiveWriteInput) => Promise<void>;
34
40
  readonly bodyLimitBytes?: number;
35
41
  }
42
+ export interface FoundationGraphObjectiveWriteInput {
43
+ readonly definition: ObjectiveDefinition;
44
+ readonly expectedRevision: string | null;
45
+ readonly projectCode: string;
46
+ readonly identity: FoundationGraphTrustedIdentity;
47
+ readonly client: FoundationGraphQueryClient;
48
+ }
36
49
  /**
37
50
  * Create the reusable Graph-backed public Foundation handler.
38
51
  *
@@ -3,6 +3,8 @@ import { checkObjectiveReadiness } from './company-os-objectives.js';
3
3
  import { createFoundationPublicProvider, createFoundationPublicRoute } from './foundation-public-provider.js';
4
4
  import { createGraphFoundationReaders } from './graph-foundation-reader.js';
5
5
  import { FoundationStoreError } from './foundation-store.js';
6
+ import { digestFoundationDefinition, nextFoundationRevision } from './foundation-catalog.js';
7
+ import { validateFoundationGraphWrite } from './foundation-graph-write.js';
6
8
  /**
7
9
  * Readiness probe for the Graph history contract used by the public adapter.
8
10
  *
@@ -24,6 +26,54 @@ export const FOUNDATION_GRAPH_READY_SQL = `SELECT
24
26
  export const FOUNDATION_GRAPH_CONTRACT_VERSION = 'foundation-graph-http.v1';
25
27
  export const FOUNDATION_OVERVIEW_CONTRACT_VERSION = 'foundation-overview.v1';
26
28
  const FOUNDATION_CATALOG_PATH = '/api/foundation/catalog';
29
+ class FoundationGraphResponseError extends Error {
30
+ response;
31
+ constructor(response) {
32
+ super('Foundation mutation was rejected');
33
+ this.response = response;
34
+ }
35
+ }
36
+ /** Domain proposals can never supply the host's authority or provenance. */
37
+ function graphObjectiveBuilder(projectCode) {
38
+ return ({ operation, body, context, current, expectedRevision }) => {
39
+ if (body.adoptionState !== undefined && body.adoptionState !== 'draft') {
40
+ throw new FoundationStoreError('invalid_input', 'Objective writes accept draft definitions only');
41
+ }
42
+ const domain = {};
43
+ for (const key of ['meaning', 'desiredState', 'beneficiaryIds', 'criteria', 'evaluationPeriod', 'accountableId', 'epistemicState']) {
44
+ if (body[key] !== undefined)
45
+ domain[key] = body[key];
46
+ }
47
+ const blank = (value) => value === undefined || value === null || (typeof value === 'string' && !value.trim());
48
+ if (isRecord(domain.evaluationPeriod)
49
+ && blank(domain.evaluationPeriod.from) && blank(domain.evaluationPeriod.until))
50
+ delete domain.evaluationPeriod;
51
+ if (blank(domain.accountableId))
52
+ delete domain.accountableId;
53
+ if (operation === 'update') {
54
+ if (!current)
55
+ throw new FoundationStoreError('not_found', 'The current Objective is required');
56
+ if (current.revision !== expectedRevision) {
57
+ throw new FoundationStoreError('revision_conflict', 'Objective changed; reload before saving', { currentRevision: current.revision });
58
+ }
59
+ return {
60
+ ...domain, id: current.id, type: 'objective', revision: nextFoundationRevision(current.revision),
61
+ adoptionState: 'draft', acl: current.acl, scope: current.scope, storage: current.storage,
62
+ provenance: current.provenance, authorizedUses: current.authorizedUses
63
+ };
64
+ }
65
+ const id = typeof body.id === 'string' ? body.id.trim() : '';
66
+ if (!id)
67
+ throw new FoundationStoreError('invalid_input', 'id is required');
68
+ return {
69
+ ...domain, id, type: 'objective', revision: '1', adoptionState: 'draft',
70
+ acl: { ownerId: context.principal, visibility: 'private', readerIds: [], writerIds: [] },
71
+ scope: { subjectIds: [projectCode], validFrom: new Date().toISOString() },
72
+ storage: 'candidate', authorizedUses: ['draft'],
73
+ provenance: [{ sourceId: 'foundation-objective-editor', sourceKind: 'candidate', evidenceIds: [] }]
74
+ };
75
+ };
76
+ }
27
77
  const DEFAULT_BODY_LIMIT_BYTES = 65_536;
28
78
  const DIRECT_FOUNDATION_REFERENCE_KINDS = new Set(['objective', 'variable', 'model', 'constraint', 'philosophy']);
29
79
  const SNAPSHOT_REFERENCE_KINDS = new Set([
@@ -298,19 +348,47 @@ export function createFoundationGraphHttpHandler(options) {
298
348
  const provider = createFoundationPublicProvider(readers);
299
349
  const route = createFoundationPublicRoute(provider);
300
350
  const catalogRoute = createFoundationCatalogRoute(readers, scopeId, context);
351
+ const writeObjective = async (definition, expectedRevision) => {
352
+ if (!options.writeObjectiveDraft) {
353
+ throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured');
354
+ }
355
+ const current = expectedRevision === null ? undefined
356
+ : await readers.store.readLatest('objective', definition.id, context);
357
+ if (expectedRevision !== null && !current)
358
+ throw new FoundationStoreError('not_found', 'Objective was not found');
359
+ if (current && current.definition.revision !== expectedRevision) {
360
+ throw new FoundationStoreError('revision_conflict', 'Objective changed; reload before saving', { currentRevision: current.definition.revision });
361
+ }
362
+ const checked = validateFoundationGraphWrite({
363
+ context: { principal: identity.principal, projectCode: scopeId },
364
+ row: { id: definition.id, type: 'objective', projectCode: scopeId, revision: definition.revision, payload: { foundation: definition } },
365
+ expectedNextRevision: expectedRevision === null ? '1' : nextFoundationRevision(expectedRevision),
366
+ ...(current ? { currentDefinition: current.definition } : {})
367
+ });
368
+ if (!checked.valid || !checked.normalized) {
369
+ const denied = checked.issues.some((issue) => ['WRITER_NOT_AUTHORIZED', 'OWNER_MISMATCH', 'ACL_CHANGE_FORBIDDEN', 'SCOPE_VIOLATION'].includes(issue.code));
370
+ throw new FoundationStoreError(denied ? 'authorization_denied' : 'invalid_input', 'Objective draft validation failed');
371
+ }
372
+ const candidate = checked.normalized.definition;
373
+ await options.writeObjectiveDraft({ definition: candidate, expectedRevision, projectCode: scopeId, identity, client });
374
+ const saved = await readers.store.read({ id: candidate.id, type: 'objective', revision: candidate.revision }, context);
375
+ const digest = digestFoundationDefinition(candidate);
376
+ if (!saved || saved.digest !== digest) {
377
+ throw new FoundationStoreError('readback_mismatch', 'The saved Objective revision could not be verified');
378
+ }
379
+ return { id: candidate.id, type: 'objective', revision: candidate.revision, digest };
380
+ };
301
381
  const objectiveRoute = createObjectiveFoundationRoute({
382
+ ...(options.writeObjectiveDraft ? { buildDefinition: graphObjectiveBuilder(scopeId) } : {}),
383
+ bodyLimitBytes,
302
384
  objectives: {
303
- async createObjective() {
304
- throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured for the Graph read adapter');
305
- },
385
+ createObjective: (definition) => writeObjective(definition, null),
306
386
  async readObjective(id, objectiveContext, revision) {
307
387
  return revision === undefined
308
388
  ? readers.store.readLatest('objective', id, objectiveContext)
309
389
  : readers.store.read({ id, type: 'objective', revision }, objectiveContext);
310
390
  },
311
- async updateObjective() {
312
- throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured for the Graph read adapter');
313
- },
391
+ updateObjective: (_id, expectedRevision, definition) => writeObjective(definition, expectedRevision),
314
392
  async checkObjectiveReadiness(id, objectiveContext, revision) {
315
393
  return checkObjectiveReadiness(readers.store, id, objectiveContext, revision);
316
394
  },
@@ -323,13 +401,21 @@ export function createFoundationGraphHttpHandler(options) {
323
401
  routes: [catalogRoute, route, objectiveRoute],
324
402
  bodyLimitBytes
325
403
  });
326
- return handler.handle(request);
404
+ const result = await handler.handle(request);
405
+ // The common HTTP router renders domain errors. Re-throw the response
406
+ // until the host transaction rolls back, including failed readback.
407
+ if (!['GET', 'HEAD'].includes(request.method.toUpperCase()) && result.status >= 400) {
408
+ throw new FoundationGraphResponseError(result);
409
+ }
410
+ return result;
327
411
  });
328
412
  return response instanceof Response
329
413
  ? response
330
414
  : jsonResponse(503, { error: { code: 'foundation_provider_unavailable' } });
331
415
  }
332
- catch {
416
+ catch (error) {
417
+ if (error instanceof FoundationGraphResponseError)
418
+ return error.response;
333
419
  return jsonResponse(503, { error: { code: 'foundation_provider_unavailable' } });
334
420
  }
335
421
  }
@@ -77,6 +77,15 @@ export interface ObjectiveFoundationHttpRouteOptions {
77
77
  replaceObjectiveConstraintRefs?: (reference: FoundationRef, refs: readonly FoundationRevision[], context: FoundationStoreContext) => MaybePromise<unknown>;
78
78
  bodyLimitBytes?: number;
79
79
  }
80
+ /** Typed, explicitly sanitized error for trusted host adapters. */
81
+ export declare class FoundationHttpError extends Error {
82
+ readonly status: number;
83
+ readonly code: string;
84
+ readonly currentRevision?: string;
85
+ constructor(status: number, code: string, message: string, options?: {
86
+ currentRevision?: string;
87
+ });
88
+ }
80
89
  /**
81
90
  * Compose OSS Company OS routes under one local server boundary. The
82
91
  * resolver is called only for a matching route and the resulting trusted
@@ -65,7 +65,8 @@ const AUTHORITY_FIELDS = new Set([
65
65
  'permission',
66
66
  'permissions',
67
67
  ]);
68
- class FoundationHttpError extends Error {
68
+ /** Typed, explicitly sanitized error for trusted host adapters. */
69
+ export class FoundationHttpError extends Error {
69
70
  status;
70
71
  code;
71
72
  currentRevision;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.22.3",
3
+ "version": "0.22.5",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -386,7 +386,7 @@
386
386
  "build:world": "node scripts/build-world-vendor.mjs"
387
387
  },
388
388
  "dependencies": {
389
- "@modelcontextprotocol/sdk": "^1.30.0",
389
+ "@modelcontextprotocol/sdk": "^1.31.0",
390
390
  "graphology": "0.26.0",
391
391
  "sigma": "3.0.3",
392
392
  "zod": "^3.25.56"
@@ -407,5 +407,5 @@
407
407
  "engines": {
408
408
  "node": ">=20"
409
409
  },
410
- "gitHead": "8e7c9b80910f862f297788c35096461e3359d465"
410
+ "gitHead": "c361ea6dc167dd1ffe372a6f0d6c1f445d0d5ebf"
411
411
  }
@@ -298,6 +298,9 @@ export function createGraphProjectsView({
298
298
  let contextRequest = 0;
299
299
  let detailRequest = 0;
300
300
  let hostListRequest = 0;
301
+ // An explicitly selected project may be read in parallel with the host
302
+ // list, but the list remains authoritative for whether its detail is shown.
303
+ let initialSelectionGate = null;
301
304
  let viewDestroyed = false;
302
305
 
303
306
  const correction = createGraphCorrection({
@@ -415,7 +418,7 @@ export function createGraphProjectsView({
415
418
  }
416
419
 
417
420
  /** Reads supplementary sections independently of the Graph project detail. */
418
- async function loadProjectContextFor(id, { keep = false } = {}) {
421
+ async function loadProjectContextFor(id, { keep = false, deferUntilHostList = null } = {}) {
419
422
  if (viewDestroyed) return state.context;
420
423
  if (typeof loadProjectContext !== 'function') {
421
424
  if (state.context?.projectId !== id) state.context = null;
@@ -425,9 +428,16 @@ export function createGraphProjectsView({
425
428
  const request = ++contextRequest;
426
429
  state.context = normalizeProjectContext(id, { state: 'loading' });
427
430
  controller.render();
428
- const applyContext = (snapshot) => {
431
+ const applyContext = (snapshot, { flushDeferred = false } = {}) => {
429
432
  const normalized = normalizeProjectContext(id, snapshot);
430
433
  if (viewDestroyed || request !== contextRequest || state.selectedId !== id) return false;
434
+ if (deferUntilHostList && !deferUntilHostList.confirmed && !flushDeferred) {
435
+ if (!deferUntilHostList.cancelled && initialSelectionGate === deferUntilHostList) {
436
+ deferUntilHostList.pendingContext = normalized;
437
+ deferUntilHostList.flushContext = () => applyContext(normalized, { flushDeferred: true });
438
+ }
439
+ return false;
440
+ }
431
441
  state.context = normalized;
432
442
  // Context progress must not rebuild the workspace: rebuilding destroys
433
443
  // Sigma's viewport and resets the user's active tab/selection. Detail
@@ -796,6 +806,37 @@ export function createGraphProjectsView({
796
806
  return children;
797
807
  }
798
808
 
809
+ function cancelInitialSelectionGate(gate, { reset = true } = {}) {
810
+ if (!gate || gate.cancelled || gate.confirmed) return;
811
+ gate.cancelled = true;
812
+ gate.pendingContext = null;
813
+ gate.pendingDetail = null;
814
+ gate.flushContext = null;
815
+ gate.flushDetail = null;
816
+ if (initialSelectionGate === gate) initialSelectionGate = null;
817
+ // A caller may have selected another project while this gate was pending.
818
+ // Do not invalidate that newer project's reads in that case.
819
+ if (!reset || state.selectedId !== gate.id) return;
820
+ detailRequest += 1;
821
+ contextRequest += 1;
822
+ if (state.detail?.id === gate.id && state.detail.state === 'loading') state.detail = null;
823
+ if (state.context?.projectId === gate.id && state.context.state === 'loading') state.context = null;
824
+ }
825
+
826
+ function confirmInitialSelectionGate(gate) {
827
+ if (!gate || gate.cancelled) return;
828
+ gate.confirmed = true;
829
+ if (initialSelectionGate === gate) initialSelectionGate = null;
830
+ // Responses which finished before the list are safe to expose now. A
831
+ // response still in flight will apply through its normal completion path.
832
+ gate.flushDetail?.();
833
+ gate.flushContext?.();
834
+ gate.pendingDetail = null;
835
+ gate.pendingContext = null;
836
+ gate.flushDetail = null;
837
+ gate.flushContext = null;
838
+ }
839
+
799
840
  /** Reads the host's project list (see `load`). */
800
841
  async function loadHostList({ keep = false } = {}) {
801
842
  const request = ++hostListRequest;
@@ -804,14 +845,45 @@ export function createGraphProjectsView({
804
845
  state.list = { state: 'loading' };
805
846
  controller.render();
806
847
  }
848
+ let selectionGate = null;
849
+ let selectionDetailReading = null;
850
+ const selectedBeforeList = state.selectedId;
851
+ if (selectedBeforeList) {
852
+ if (initialSelectionGate?.id === selectedBeforeList && !initialSelectionGate.cancelled && !initialSelectionGate.confirmed) {
853
+ selectionGate = initialSelectionGate;
854
+ selectionDetailReading = selectionGate.detailReading;
855
+ } else if (state.detail === null) {
856
+ selectionGate = {
857
+ id: selectedBeforeList,
858
+ confirmed: false,
859
+ cancelled: false,
860
+ pendingContext: null,
861
+ pendingDetail: null,
862
+ flushContext: null,
863
+ flushDetail: null,
864
+ detailReading: null,
865
+ };
866
+ initialSelectionGate = selectionGate;
867
+ selectionDetailReading = controller.loadDetail(selectedBeforeList, {
868
+ keep,
869
+ deferUntilHostList: selectionGate,
870
+ });
871
+ selectionGate.detailReading = selectionDetailReading;
872
+ }
873
+ }
807
874
  const result = await client.read('/projects');
808
875
  if (viewDestroyed || request !== hostListRequest) return state.list;
809
876
  state.list = normalizeProjectList(result);
810
877
  if (state.list.state !== 'ok') {
878
+ cancelInitialSelectionGate(selectionGate);
811
879
  controller.render();
812
880
  return state.list;
813
881
  }
814
882
  const { projects } = state.list.payload;
883
+ if (selectionGate && (selectionGate.cancelled || state.selectedId !== selectionGate.id)) {
884
+ cancelInitialSelectionGate(selectionGate, { reset: false });
885
+ selectionGate = null;
886
+ }
815
887
  if (!state.selectedId) {
816
888
  if (correction.form) correction.close();
817
889
  state.panelAt = null;
@@ -832,6 +904,7 @@ export function createGraphProjectsView({
832
904
  // never be replaced with the first row when the refreshed list no
833
905
  // longer contains it. Keeping the id makes the missing target visible
834
906
  // and lets the host decide whether its identity or access changed.
907
+ cancelInitialSelectionGate(selectionGate);
835
908
  if (correction.form) correction.close();
836
909
  detailRequest += 1;
837
910
  state.panelAt = null;
@@ -842,6 +915,11 @@ export function createGraphProjectsView({
842
915
  };
843
916
  state.context = null;
844
917
  contextRequest += 1;
918
+ } else if (selectionGate && selectionGate.id === state.selectedId) {
919
+ confirmInitialSelectionGate(selectionGate);
920
+ if (selectionDetailReading) await selectionDetailReading;
921
+ if (viewDestroyed || request !== hostListRequest) return state.list;
922
+ return state.list;
845
923
  } else if (state.detail?.id !== state.selectedId || state.detail?.state === 'not_found') {
846
924
  // The project the host asked for is listed; read it for the rail.
847
925
  if (viewDestroyed || request !== hostListRequest) return state.list;
@@ -876,6 +954,7 @@ export function createGraphProjectsView({
876
954
  hostListRequest += 1;
877
955
  detailRequest += 1;
878
956
  contextRequest += 1;
957
+ initialSelectionGate = null;
879
958
  knowledgeWorkspace?.destroy();
880
959
  knowledgeWorkspace = null;
881
960
  correction.close();
@@ -925,7 +1004,7 @@ export function createGraphProjectsView({
925
1004
  return state.own.detail;
926
1005
  },
927
1006
  /** `keep` shows the current detail until the new one has been read. */
928
- async loadDetail(id, { keep = false } = {}) {
1007
+ async loadDetail(id, { keep = false, deferUntilHostList = null } = {}) {
929
1008
  if (viewDestroyed) return state.detail;
930
1009
  const request = ++detailRequest;
931
1010
  const previous = state.detail;
@@ -933,13 +1012,26 @@ export function createGraphProjectsView({
933
1012
  state.detail = { id, state: 'loading' };
934
1013
  controller.render();
935
1014
  }
936
- const contextReading = loadProjectContextFor(id, { keep });
1015
+ const contextReading = loadProjectContextFor(id, { keep, deferUntilHostList });
937
1016
  const result = await client.read(`/projects/${encodeURIComponent(id)}`);
938
1017
  if (viewDestroyed || request !== detailRequest || state.selectedId !== id) return state.detail;
939
- state.detail = result.state === 'error' && result.status === 404
1018
+ const detail = result.state === 'error' && result.status === 404
940
1019
  ? { id, state: 'not_found', reason: 'このプロジェクトは見つかりません。一覧を読み直してください。' }
941
1020
  : { id, ...normalizeProjectDetail(result) };
942
- controller.render();
1021
+ const applyDetail = () => {
1022
+ if (viewDestroyed || request !== detailRequest || state.selectedId !== id) return false;
1023
+ state.detail = detail;
1024
+ controller.render();
1025
+ return true;
1026
+ };
1027
+ if (deferUntilHostList && !deferUntilHostList.confirmed) {
1028
+ if (!deferUntilHostList.cancelled && initialSelectionGate === deferUntilHostList) {
1029
+ deferUntilHostList.pendingDetail = detail;
1030
+ deferUntilHostList.flushDetail = applyDetail;
1031
+ }
1032
+ return state.detail;
1033
+ }
1034
+ applyDetail();
943
1035
  // The context is intentionally independent. Its completion may trigger
944
1036
  // one more render, but never changes the Graph detail result.
945
1037
  void contextReading;
@@ -947,6 +1039,10 @@ export function createGraphProjectsView({
947
1039
  },
948
1040
  /** Selects a row: the rail shows that project, and an open correction is closed. */
949
1041
  async selectProject(id) {
1042
+ // A host selection supersedes a prefetched initial detail. Cancel the
1043
+ // gate before changing the id so A -> B -> A cannot make a later list
1044
+ // response wait for the first A read.
1045
+ cancelInitialSelectionGate(initialSelectionGate, { reset: false });
950
1046
  if (correction.form) correction.close();
951
1047
  state.panelAt = null;
952
1048
  state.selectedId = id;
@@ -220,6 +220,17 @@ function canonicalDateTimeValue(value) {
220
220
  return Number.isNaN(date.getTime()) ? input : date.toISOString();
221
221
  }
222
222
 
223
+ // Empty date inputs are an unspecified draft period. A partial or invalid
224
+ // period must still reach canonical validation instead of being discarded.
225
+ function definitionForSave(definition) {
226
+ const draft = JSON.parse(JSON.stringify(definition));
227
+ const period = objectValue(draft.evaluationPeriod);
228
+ const blankDate = (value) => value === null || value === undefined || (typeof value === 'string' && !value.trim());
229
+ if (period && Object.keys(period).every((key) => ['from', 'until'].includes(key))
230
+ && blankDate(period.from) && blankDate(period.until)) delete draft.evaluationPeriod;
231
+ return draft;
232
+ }
233
+
223
234
  function normalizeReadiness(value) {
224
235
  const object = objectValue(value);
225
236
  if (!object || typeof object.ready !== 'boolean') return { state: 'unknown', ready: null, issues: null };
@@ -698,7 +709,7 @@ function renderCriteria(root, draft, callbacks) {
698
709
  return fieldset;
699
710
  }
700
711
 
701
- function collectDefinition(form, baseDraft) {
712
+ function collectDefinition(form, baseDraft, { keepEmptyCriteria = false } = {}) {
702
713
  const draft = JSON.parse(JSON.stringify(baseDraft ?? {}));
703
714
  const value = (name) => findField(form, name)?.value ?? '';
704
715
  draft.id = optionalText(value('id')).trim();
@@ -719,7 +730,7 @@ function collectDefinition(form, baseDraft) {
719
730
  const variableRevision = value(`criteria.${index}.variable_revision`).trim();
720
731
  const operator = value(`criteria.${index}.operator`) || 'equals';
721
732
  const target = parseTarget(value(`criteria.${index}.target`));
722
- if (!variableIdInput.value.trim() && !variableRevision) continue;
733
+ if (!keepEmptyCriteria && !variableIdInput.value.trim() && !variableRevision) continue;
723
734
  const criterion = {
724
735
  variableRef: { id: variableIdInput.value.trim(), type: 'variable', revision: variableRevision },
725
736
  operator,
@@ -727,7 +738,7 @@ function collectDefinition(form, baseDraft) {
727
738
  if (target !== undefined) criterion.target = target;
728
739
  draft.criteria.push(criterion);
729
740
  }
730
- return draft;
741
+ return definitionForSave(draft);
731
742
  }
732
743
 
733
744
  function renderObjectiveForm(root, state, callbacks) {
@@ -742,7 +753,9 @@ function renderObjectiveForm(root, state, callbacks) {
742
753
  makeElement('div', { className: 'objective-editor-panel-heading', text: title }),
743
754
  makeElement('p', { className: 'objective-editor-muted', text: '保存すると新しい版になり、保存した版を読み戻して表示します。' }),
744
755
  );
745
- if (state.save.state !== 'idle' && state.save.state !== 'saving') section.append(statusNotice(state.save, { onRetry: callbacks.onRetrySave }));
756
+ if (state.save.state !== 'idle' && state.save.state !== 'saving') section.append(statusNotice(state.save, {
757
+ onRetry: ['conflict', 'saved_unverified'].includes(state.save.state) ? null : callbacks.onRetrySave,
758
+ }));
746
759
  section.append(buildObjectiveForm(state, callbacks));
747
760
  return section;
748
761
  }
@@ -776,18 +789,36 @@ function buildObjectiveForm(state, callbacks, { workspace = false } = {}) {
776
789
  { value: 'retired', label: '終了' },
777
790
  ] }).label);
778
791
  form.append(governance);
779
- form.append(renderCriteria(form, draft, callbacks));
792
+ const captureDraft = () => callbacks.onDraftChange?.(collectDefinition(form, draft, { keepEmptyCriteria: true }));
793
+ form.append(renderCriteria(form, draft, {
794
+ ...callbacks,
795
+ onAddCriterion: () => { captureDraft(); callbacks.onAddCriterion?.(); },
796
+ onRemoveCriterion: (index) => { captureDraft(); callbacks.onRemoveCriterion?.(index); },
797
+ }));
780
798
  form.append(renderReadiness(form, state.readiness));
781
799
  form.append(renderConstraintRefs(form, state, callbacks));
782
800
  const actions = makeElement('div', { className: 'objective-editor-actions' });
783
801
  actions.append(makeElement('span', { className: 'objective-editor-muted', text: callbacks.canEdit ? '保存後に同じID・新版を再取得して確認します。' : '読み取り専用です。書込み権限はホストから提供してください。' }));
784
- const saving = state.save.state === 'saving';
785
- if (callbacks.canEdit) actions.append(makeElement('button', { className: workspace ? 'bb-ws-button is-primary' : 'objective-editor-button-primary', text: saving ? '保存中…' : '保存', attrs: { type: 'submit' }, disabled: saving }));
786
- if (workspace) actions.append(makeButton('キャンセル', callbacks.onCancelEdit, 'bb-ws-button'));
787
- else actions.append(makeButton('一覧へ戻る', callbacks.onBack, 'objective-editor-button-secondary'));
802
+ const saving = callbacks.saving || state.save.state === 'saving';
803
+ const unverified = state.save.state === 'saved_unverified';
804
+ if (saving) actions.append(makeElement('span', { className: 'objective-editor-muted', text: '保存中の処理は、画面を閉じても続きます。' }));
805
+ if (unverified) actions.append(makeElement('span', { className: 'objective-editor-muted', text: '一覧を再読込し、保存した目的を選び直して確かめてください。' }));
806
+ if (callbacks.canEdit) actions.append(makeElement('button', { className: workspace ? 'bb-ws-button is-primary' : 'objective-editor-button-primary', text: saving ? '保存中…' : '保存', attrs: { type: 'submit' }, disabled: saving || unverified }));
807
+ const dismiss = workspace
808
+ ? makeButton('キャンセル', callbacks.onCancelEdit, 'bb-ws-button')
809
+ : makeButton('一覧へ戻る', callbacks.onBack, 'objective-editor-button-secondary');
810
+ actions.append(dismiss);
788
811
  form.append(actions);
812
+ if (saving) {
813
+ for (const control of findAll(form, (node) => ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(node.tagName))) {
814
+ if (control !== dismiss) control.disabled = true;
815
+ }
816
+ }
817
+ form.addEventListener('input', captureDraft);
818
+ form.addEventListener('change', captureDraft);
789
819
  form.addEventListener('submit', (event) => {
790
820
  event.preventDefault();
821
+ if (saving || unverified) return;
791
822
  callbacks.onSubmit?.(collectDefinition(form, draft));
792
823
  });
793
824
  return form;
@@ -1107,7 +1138,7 @@ function renderObjectiveFormRail(doc, state, callbacks) {
1107
1138
  // A conflict keeps the input; saving again with the same revision would fail again, so no retry there.
1108
1139
  parts.push(workspaceStatusNotice(doc, state.save, {
1109
1140
  label: '保存',
1110
- onRetry: state.save.state === 'conflict' ? null : callbacks.onRetrySave,
1141
+ onRetry: ['conflict', 'saved_unverified'].includes(state.save.state) ? null : callbacks.onRetrySave,
1111
1142
  retryLabel: 'もう一度保存',
1112
1143
  }));
1113
1144
  }
@@ -1219,20 +1250,35 @@ export function createObjectiveEditorController(options = {}) {
1219
1250
  };
1220
1251
  // The objective shown before 新しい目的, to return to when the owner cancels.
1221
1252
  let beforeCreate = null;
1253
+ // Requests may finish after Cancel or a newer selection. Only the current
1254
+ // screen may consume their results; the save lock survives those changes.
1255
+ let selectionVersion = 0;
1256
+ let editorVersion = 0;
1257
+ let listRequest = 0;
1258
+ let readinessRequest = 0;
1259
+ let constraintRequest = 0;
1260
+ let renderVersion = 0;
1261
+ let activeSave = null;
1222
1262
 
1223
1263
  function render() {
1224
1264
  if (!root) return null;
1265
+ const currentRender = ++renderVersion;
1266
+ const currentEditor = editorVersion;
1225
1267
  const callbacks = {
1226
1268
  canEdit,
1269
+ saving: activeSave !== null,
1227
1270
  constraintsEditable,
1228
1271
  storyLinks,
1229
1272
  onView: (view) => { state.view = view; render(); },
1230
1273
  onReload: loadObjectives,
1231
1274
  onCreate: beginCreate,
1232
1275
  onSelect: selectObjective,
1233
- onBack: () => { state.view = 'list'; render(); },
1234
- onSubmit: saveObjective,
1235
- onRetrySave: () => state.editor.mode === 'create' ? saveObjective(state.editor.draft) : saveObjective(state.editor.draft),
1276
+ onBack: () => { cancelEdit(); state.view = 'list'; render(); },
1277
+ onSubmit: (definition) => currentEditor === editorVersion && currentRender === renderVersion ? saveObjective(definition) : null,
1278
+ onDraftChange: (definition) => {
1279
+ if (currentEditor === editorVersion && currentRender === renderVersion && !activeSave) state.editor.draft = definition;
1280
+ },
1281
+ onRetrySave: () => saveObjective(state.editor.draft),
1236
1282
  onAddCriterion: () => { state.editor.draft.criteria = [...(state.editor.draft.criteria ?? []), { variableRef: { id: '', type: 'variable', revision: '' }, operator: 'equals' }]; render(); },
1237
1283
  onRemoveCriterion: (index) => { state.editor.draft.criteria = (state.editor.draft.criteria ?? []).filter((_, itemIndex) => itemIndex !== index); render(); },
1238
1284
  onAddConstraintRef: addConstraintRef,
@@ -1243,6 +1289,10 @@ export function createObjectiveEditorController(options = {}) {
1243
1289
  onEdit: beginEdit,
1244
1290
  onCancelEdit: cancelEdit,
1245
1291
  };
1292
+ // Detached forms and buttons must not act on a newer editor session.
1293
+ for (const [name, callback] of Object.entries(callbacks)) {
1294
+ if (typeof callback === 'function') callbacks[name] = (...args) => currentRender === renderVersion ? callback(...args) : undefined;
1295
+ }
1246
1296
  return rail ? renderObjectiveWorkspace(root, rail, state, callbacks, page, options) : renderObjectiveEditor(root, state, callbacks);
1247
1297
  }
1248
1298
 
@@ -1256,6 +1306,8 @@ export function createObjectiveEditorController(options = {}) {
1256
1306
  }
1257
1307
 
1258
1308
  async function loadObjectives() {
1309
+ const request = ++listRequest;
1310
+ const version = editorVersion;
1259
1311
  state.objectives = { state: 'loading', records: null, absence_confirmed: false };
1260
1312
  state.connection = port ? 'ready' : 'api_unavailable';
1261
1313
  render();
@@ -1267,20 +1319,26 @@ export function createObjectiveEditorController(options = {}) {
1267
1319
  return state.objectives;
1268
1320
  }
1269
1321
  try {
1270
- state.objectives = normalizeObjectiveCollection(await method(context));
1322
+ const payload = await method(context);
1323
+ if (request !== listRequest) return state.objectives;
1324
+ state.objectives = normalizeObjectiveCollection(payload);
1271
1325
  state.connection = state.objectives.state === 'ready' || state.objectives.state === 'empty' ? 'ready' : state.objectives.state;
1272
1326
  } catch (error) {
1327
+ if (request !== listRequest) return state.objectives;
1273
1328
  state.objectives = { state: normalizeObjectiveError(error).state, records: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
1274
1329
  state.connection = state.objectives.state;
1275
1330
  }
1276
1331
  render();
1277
1332
  // As on the organization screens, the workspace opens with the first row selected.
1278
1333
  const first = state.objectives.records?.[0];
1279
- if (rail && first && !state.selectedId && state.railMode !== 'form') await selectObjective(first);
1334
+ if (rail && first && !state.selectedId && state.railMode !== 'form' && version === editorVersion) await selectObjective(first);
1280
1335
  return state.objectives;
1281
1336
  }
1282
1337
 
1283
1338
  async function loadConstraintRefs(objective) {
1339
+ const version = selectionVersion;
1340
+ const request = ++constraintRequest;
1341
+ const current = () => version === selectionVersion && request === constraintRequest;
1284
1342
  state.constraints = { state: 'loading', refs: null, absence_confirmed: false };
1285
1343
  render();
1286
1344
  if (!objective) {
@@ -1297,13 +1355,15 @@ export function createObjectiveEditorController(options = {}) {
1297
1355
  }
1298
1356
  try {
1299
1357
  const payload = await method({ id: objective.id, type: 'objective', revision: objective.revision }, context);
1358
+ if (!current()) return state.constraints;
1300
1359
  state.constraints = normalizeReferenceCollection(payload, ['refs', 'references', 'constraints', 'records', 'items', 'data']);
1301
- if (state.constraints.refs) {
1360
+ if (state.constraints.refs && !state.editor.constraintRefsChanged) {
1302
1361
  state.editor.constraintRefs = state.constraints.refs;
1303
1362
  state.editor.originalConstraintRefs = JSON.parse(JSON.stringify(state.constraints.refs));
1304
1363
  state.editor.constraintRefsChanged = false;
1305
1364
  }
1306
1365
  } catch (error) {
1366
+ if (!current()) return state.constraints;
1307
1367
  state.constraints = { state: normalizeObjectiveError(error).state, refs: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
1308
1368
  }
1309
1369
  render();
@@ -1312,6 +1372,10 @@ export function createObjectiveEditorController(options = {}) {
1312
1372
 
1313
1373
  async function loadReadiness(objective) {
1314
1374
  if (!objective) return state.readiness;
1375
+ const version = selectionVersion;
1376
+ const request = ++readinessRequest;
1377
+ const current = () => version === selectionVersion && request === readinessRequest
1378
+ && state.selected?.id === objective.id && state.selected?.revision === objective.revision;
1315
1379
  const method = portMethod(port, 'checkObjectiveReadiness');
1316
1380
  if (!method) {
1317
1381
  state.readiness = { state: 'unknown', ready: null, issues: null, message: '判断に使える状態を確かめる経路がありません。' };
@@ -1319,8 +1383,11 @@ export function createObjectiveEditorController(options = {}) {
1319
1383
  return state.readiness;
1320
1384
  }
1321
1385
  try {
1322
- state.readiness = normalizeReadiness(await method(objective.id, context, objective.revision));
1386
+ const payload = await method(objective.id, context, objective.revision);
1387
+ if (!current()) return state.readiness;
1388
+ state.readiness = normalizeReadiness(payload);
1323
1389
  } catch (error) {
1390
+ if (!current()) return state.readiness;
1324
1391
  state.readiness = { state: normalizeObjectiveError(error).state, ready: null, issues: null, ...normalizeObjectiveError(error) };
1325
1392
  }
1326
1393
  if (state.selected) state.selected.readiness = state.readiness;
@@ -1333,10 +1400,13 @@ export function createObjectiveEditorController(options = {}) {
1333
1400
  const id = reference?.id;
1334
1401
  const revision = reference?.revision;
1335
1402
  if (!id) return null;
1403
+ const version = ++selectionVersion;
1404
+ editorVersion += 1;
1405
+ state.selected = null;
1336
1406
  state.selectedId = id;
1407
+ state.save = { state: 'idle' };
1337
1408
  if (rail) {
1338
1409
  state.railMode = 'detail';
1339
- state.save = { state: 'idle' };
1340
1410
  beforeCreate = null;
1341
1411
  }
1342
1412
  state.view = 'editor';
@@ -1353,6 +1423,7 @@ export function createObjectiveEditorController(options = {}) {
1353
1423
  }
1354
1424
  try {
1355
1425
  const payload = await method(id, context, revision);
1426
+ if (version !== selectionVersion) return null;
1356
1427
  const objective = normalizeObjectiveRecord(payload);
1357
1428
  if (!objective) {
1358
1429
  state.editor = { ...state.editor, state: 'missing', message: '指定した目的を保存先から確認できません。' };
@@ -1370,6 +1441,7 @@ export function createObjectiveEditorController(options = {}) {
1370
1441
  await Promise.allSettled([loadReadiness(objective), loadConstraintRefs(objective)]);
1371
1442
  return objective;
1372
1443
  } catch (error) {
1444
+ if (version !== selectionVersion) return null;
1373
1445
  const normalized = normalizeObjectiveError(error);
1374
1446
  state.editor = { ...state.editor, state: normalized.state, message: normalized.message, error: normalized.error };
1375
1447
  state.connection = normalized.state;
@@ -1379,6 +1451,10 @@ export function createObjectiveEditorController(options = {}) {
1379
1451
  }
1380
1452
 
1381
1453
  function beginCreate() {
1454
+ // A repeated click on New must not throw away an in-progress draft.
1455
+ if (state.editor.mode === 'create' && state.railMode === 'form') return;
1456
+ selectionVersion += 1;
1457
+ editorVersion += 1;
1382
1458
  if (rail && state.railMode !== 'form') beforeCreate = state.selected;
1383
1459
  state.railMode = 'form';
1384
1460
  state.view = 'editor';
@@ -1399,6 +1475,8 @@ export function createObjectiveEditorController(options = {}) {
1399
1475
  /** Workspace layout: open the edit form for the selected objective in the rail. */
1400
1476
  function beginEdit() {
1401
1477
  if (!state.selected) return;
1478
+ if (state.railMode === 'form' && state.editor.mode === 'edit') return;
1479
+ editorVersion += 1;
1402
1480
  state.editor = {
1403
1481
  ...state.editor,
1404
1482
  state: 'ready', mode: 'edit', draft: editorDraftFromObjective(state.selected), expectedRevision: state.selected.revision,
@@ -1411,12 +1489,14 @@ export function createObjectiveEditorController(options = {}) {
1411
1489
  render();
1412
1490
  }
1413
1491
 
1414
- /** Workspace layout: leave the form without saving and show the objective again. */
1492
+ /** Discard unsaved input and leave the form; an already sent write cannot be undone. */
1415
1493
  function cancelEdit() {
1494
+ editorVersion += 1;
1416
1495
  const creating = state.editor.mode === 'create';
1417
1496
  state.railMode = 'detail';
1418
1497
  state.save = { state: 'idle' };
1419
1498
  if (creating) {
1499
+ selectionVersion += 1;
1420
1500
  const previous = beforeCreate;
1421
1501
  beforeCreate = null;
1422
1502
  state.editor = { state: 'idle', mode: 'edit', draft: null, expectedRevision: null, constraintRefs: null, originalConstraintRefs: null, constraintRefsChanged: false };
@@ -1438,7 +1518,7 @@ export function createObjectiveEditorController(options = {}) {
1438
1518
  }
1439
1519
 
1440
1520
  function addConstraintRef(reference) {
1441
- if (!constraintsEditable) return;
1521
+ if (!canEdit || !constraintsEditable || activeSave) return;
1442
1522
  const normalized = normalizeReference(reference, 'constraint');
1443
1523
  if (!normalized || normalized.type !== 'constraint') return;
1444
1524
  const refs = state.editor.constraintRefs ?? [];
@@ -1449,7 +1529,7 @@ export function createObjectiveEditorController(options = {}) {
1449
1529
  }
1450
1530
 
1451
1531
  function removeConstraintRef(reference) {
1452
- if (!constraintsEditable) return;
1532
+ if (!canEdit || !constraintsEditable || activeSave) return;
1453
1533
  state.editor.constraintRefs = (state.editor.constraintRefs ?? []).filter((item) => !(item.id === reference.id && item.revision === reference.revision));
1454
1534
  state.editor.constraintRefsChanged = true;
1455
1535
  render();
@@ -1457,7 +1537,16 @@ export function createObjectiveEditorController(options = {}) {
1457
1537
 
1458
1538
  async function saveObjective(definition) {
1459
1539
  if (!definition) return null;
1540
+ if (!canEdit) return { state: 'permission_denied', message: 'この画面では目的を保存できません。' };
1541
+ if (activeSave) return { state: 'saving', action: activeSave.mode };
1542
+ // Once a mutation succeeded, repeating it is unsafe until the owner has
1543
+ // reloaded and selected the canonical revision again.
1544
+ if (state.save.state === 'saved_unverified') return state.save;
1545
+ definition = definitionForSave(definition);
1460
1546
  const mode = state.editor.mode;
1547
+ const expectedRevision = state.editor.expectedRevision;
1548
+ const constraintRefsChanged = state.editor.constraintRefsChanged;
1549
+ const constraintRefs = JSON.parse(JSON.stringify(state.editor.constraintRefs ?? []));
1461
1550
  const updateMethod = mode === 'create' ? null : portMethod(port, 'updateObjective');
1462
1551
  const createMethod = mode === 'create' ? portMethod(port, 'createObjective') : null;
1463
1552
  if ((mode === 'create' && !createMethod) || (mode !== 'create' && !updateMethod)) {
@@ -1465,80 +1554,93 @@ export function createObjectiveEditorController(options = {}) {
1465
1554
  render();
1466
1555
  return state.save;
1467
1556
  }
1468
- if (state.editor.constraintRefsChanged && !portMethod(port, 'replaceObjectiveConstraintRefs')) {
1557
+ if (constraintRefsChanged && !portMethod(port, 'replaceObjectiveConstraintRefs')) {
1469
1558
  state.save = { state: 'api_unavailable', message: '制約の参照を保存する経路が無いため、目的の本文も保存していません。' };
1470
1559
  render();
1471
1560
  return state.save;
1472
1561
  }
1562
+ const version = editorVersion;
1563
+ const current = () => version === editorVersion;
1564
+ const publish = (result) => {
1565
+ if (current()) { state.save = result; render(); }
1566
+ return result;
1567
+ };
1568
+ const saving = { mode };
1569
+ activeSave = saving;
1473
1570
  state.editor.draft = definition;
1474
1571
  state.save = { state: 'saving', action: mode };
1475
1572
  render();
1573
+ let mutation;
1574
+ let mutationCompleted = false;
1476
1575
  try {
1477
- const mutation = mode === 'create'
1576
+ mutation = mode === 'create'
1478
1577
  ? await createMethod(definition, context)
1479
- : await updateMethod(definition.id, state.editor.expectedRevision, definition, context);
1578
+ : await updateMethod(definition.id, expectedRevision, definition, context);
1579
+ mutationCompleted = true;
1580
+ if (!current()) return { state: 'saved_unverified', action: mode, mutation };
1480
1581
  const savedRef = extractMutationRef(mutation);
1481
1582
  if (!savedRef || savedRef.id !== definition.id || savedRef.type !== 'objective' || !savedRef.revision) {
1482
- state.save = { state: 'saved_unverified', message: '保存の応答に、同じ目的の新しい版が含まれていません。', mutation };
1483
- render();
1484
- return state.save;
1583
+ return publish({ state: 'saved_unverified', message: '保存の応答に、同じ目的の新しい版が含まれていません。', mutation });
1485
1584
  }
1486
- const expectedRevision = state.editor.expectedRevision;
1487
1585
  if (mode !== 'create' && (!expectedRevision || savedRef.revision === expectedRevision)) {
1488
- state.save = {
1586
+ return publish({
1489
1587
  state: 'saved_unverified',
1490
1588
  message: '更新応答にexpectedRevisionとは異なる新版が含まれていません。',
1491
1589
  mutation,
1492
1590
  expectedRevision,
1493
1591
  reference: savedRef,
1494
- };
1495
- render();
1496
- return state.save;
1592
+ });
1497
1593
  }
1498
1594
  const readMethod = portMethod(port, 'readObjective');
1499
1595
  const readback = readMethod ? normalizeObjectiveRecord(await readMethod(savedRef.id, context, savedRef.revision)) : null;
1596
+ if (!current()) return { state: 'saved_unverified', action: mode, mutation, readback };
1500
1597
  const returnedDefinition = readback ? { id: readback.id, type: readback.type, revision: readback.revision, digest: readback.digest } : null;
1501
1598
  const referenceMatches = definitionsEqual(savedRef, returnedDefinition);
1502
1599
  if (!referenceMatches || !readback || (mode !== 'create' && (!expectedRevision || readback.revision === expectedRevision))) {
1503
- state.save = { state: 'saved_unverified', message: '保存した新しい版を読み戻して確かめられません。', mutation, readback };
1504
- render();
1505
- return state.save;
1600
+ return publish({ state: 'saved_unverified', message: '保存した新しい版を読み戻して確かめられません。一覧を再読込して確かめてください。', mutation, readback });
1506
1601
  }
1507
- if (state.editor.constraintRefsChanged) {
1602
+ if (constraintRefsChanged) {
1508
1603
  const replaceRefs = portMethod(port, 'replaceObjectiveConstraintRefs');
1509
- await replaceRefs(savedRef, state.editor.constraintRefs ?? [], context);
1604
+ await replaceRefs(savedRef, constraintRefs, context);
1605
+ if (!current()) return { state: 'saved_unverified', action: mode, mutation, readback };
1510
1606
  const readConstraintRefs = portMethod(port, 'listObjectiveConstraintRefs') ?? portMethod(port, 'listConstraintReferences');
1511
1607
  const constraintPayload = readConstraintRefs
1512
1608
  ? await readConstraintRefs(savedRef, context)
1513
1609
  : null;
1610
+ if (!current()) return { state: 'saved_unverified', action: mode, mutation, readback };
1514
1611
  const verifiedRefs = normalizeReferenceCollection(constraintPayload).refs;
1515
- if (!verifiedRefs || !referencesEqual(verifiedRefs, state.editor.constraintRefs)) {
1516
- state.save = { state: 'saved_unverified', message: '目的は保存されましたが、制約の参照を読み戻した内容が一致しません。', mutation, readback, constraintRefs: verifiedRefs };
1517
- render();
1518
- return state.save;
1612
+ if (!verifiedRefs || !referencesEqual(verifiedRefs, constraintRefs)) {
1613
+ return publish({ state: 'saved_unverified', message: '目的は保存されましたが、制約の参照を読み戻した内容が一致しません。', mutation, readback, constraintRefs: verifiedRefs });
1519
1614
  }
1520
1615
  }
1616
+ // Reads of the previous revision must not replace the saved revision's
1617
+ // readiness or constraints when they finish out of order.
1618
+ selectionVersion += 1;
1521
1619
  state.selected = readback;
1522
1620
  state.selectedId = readback.id;
1523
1621
  state.railMode = 'detail';
1524
1622
  beforeCreate = null;
1525
1623
  state.editor = {
1526
1624
  state: 'ready', mode: 'edit', draft: editorDraftFromObjective(readback), expectedRevision: readback.revision,
1527
- constraintRefs: state.editor.constraintRefs ?? [], originalConstraintRefs: state.editor.constraintRefs ?? [], constraintRefsChanged: false,
1625
+ constraintRefs, originalConstraintRefs: constraintRefs, constraintRefsChanged: false,
1528
1626
  };
1529
- state.save = { state: 'verified', action: mode, reference: savedRef, readback };
1627
+ const result = { state: 'verified', action: mode, reference: savedRef, readback };
1628
+ state.save = result;
1530
1629
  state.connection = 'ready';
1531
1630
  // Readiness belongs to a revision; re-read it for the one just saved.
1532
1631
  state.readiness = { state: 'unknown', ready: null, issues: null };
1533
1632
  render();
1534
1633
  await loadReadiness(readback);
1535
- await loadObjectives();
1536
- return state.save;
1634
+ if (current()) await loadObjectives();
1635
+ return result;
1537
1636
  } catch (error) {
1538
1637
  const normalized = normalizeObjectiveError(error);
1539
- state.save = { state: normalized.state, message: normalized.message, currentRevision: normalized.currentRevision, error: normalized.error, draft: definition };
1638
+ return publish(mutationCompleted
1639
+ ? { state: 'saved_unverified', message: '目的は保存されましたが、保存結果を確かめられません。一覧を再読込して確かめてください。', mutation, error: normalized.error, draft: definition }
1640
+ : { state: normalized.state, message: normalized.message, currentRevision: normalized.currentRevision, error: normalized.error, draft: definition });
1641
+ } finally {
1642
+ if (activeSave === saving) activeSave = null;
1540
1643
  render();
1541
- return state.save;
1542
1644
  }
1543
1645
  }
1544
1646