@unson/brainbase-mcp 0.40.0 → 0.41.1

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.
@@ -28,6 +28,7 @@ import {
28
28
  workspaceRailBlock,
29
29
  workspaceRailHead,
30
30
  } from './workspace-kit.js';
31
+ import { normalizeObjectiveReadback } from './objective-readback-contract.js';
31
32
 
32
33
  export const OBJECTIVE_EDITOR_CONTRACT_VERSION = 'brainbase.objective-editor.v1';
33
34
 
@@ -65,6 +66,16 @@ const STATUS_LABELS = Object.freeze({
65
66
  unknown: '確認できません',
66
67
  });
67
68
 
69
+ const OBJECTIVE_READBACK_STATUS_LABELS = Object.freeze({
70
+ ready: '確認済み',
71
+ error: '取得失敗',
72
+ unknown: '確認できません',
73
+ });
74
+
75
+ const OBJECTIVE_READBACK_REASON_MESSAGES = Object.freeze({
76
+ readback_identity_mismatch: '取得した目的が選択中の目的と一致しません。読み直してください。',
77
+ });
78
+
68
79
  const RELATION_LABELS = Object.freeze(Object.fromEntries(OBJECTIVE_EDITOR_RELATION_TYPES));
69
80
 
70
81
  function getDocument() {
@@ -129,6 +140,21 @@ function firstValue(source, ...keys) {
129
140
  return undefined;
130
141
  }
131
142
 
143
+ function sourceSummary(source) {
144
+ const object = objectValue(source);
145
+ if (!object) return '出典を確認できません。';
146
+ const fields = [
147
+ ['種別', firstValue(object, 'source_type', 'sourceType', 'kind', 'type')],
148
+ ['状態', firstValue(object, 'state', 'status', 'connection_status', 'connectionStatus')],
149
+ ['ID', firstValue(object, 'source_id', 'sourceId', 'id', 'reference')],
150
+ ['版', firstValue(object, 'source_version', 'sourceVersion', 'revision', 'version')],
151
+ ['ダイジェスト', firstValue(object, 'digest', 'hash')],
152
+ ['根拠', Array.isArray(object.provenance) ? String(object.provenance.length) + '件' : null],
153
+ ['確認日時', firstValue(object, 'checked_at', 'checkedAt', 'as_of', 'asOf')],
154
+ ];
155
+ return fields.map(([label, value]) => `${label}: ${text(value)}`).join(' ・ ');
156
+ }
157
+
132
158
  function payloadValue(payload, ...keys) {
133
159
  const object = objectValue(payload);
134
160
  if (!object) return undefined;
@@ -141,7 +167,7 @@ function payloadValue(payload, ...keys) {
141
167
  function normalizeState(value, fallback = UNKNOWN) {
142
168
  const state = optionalText(value).trim().toLowerCase();
143
169
  if (!state) return fallback;
144
- if (['forbidden', 'authorization_denied', 'permission_insufficient', 'not_allowed'].includes(state)) return 'permission_denied';
170
+ if (['forbidden', 'authorization_denied', 'permission_insufficient', 'not_allowed', 'permission_denied', 'foundation_permission_denied', 'project_not_allowed'].includes(state)) return 'permission_denied';
145
171
  if (['unavailable', 'upstream_unavailable', 'api_not_implemented', 'not_implemented'].includes(state)) return 'api_unavailable';
146
172
  if (['version_conflict', 'revision_conflict', 'cas_conflict'].includes(state)) return 'conflict';
147
173
  if (['persisted', 'saved'].includes(state)) return 'saved_unverified';
@@ -174,6 +200,7 @@ function normalizeReference(value, defaultType = null) {
174
200
  revision,
175
201
  digest: nonEmptyText(firstValue(object, 'digest', 'hash')),
176
202
  meaning: nonEmptyText(firstValue(object, 'meaning', 'label', 'name')),
203
+ source: objectValue(firstValue(object, 'source', 'provenance', 'origin')),
177
204
  raw: object,
178
205
  };
179
206
  }
@@ -235,9 +262,13 @@ function normalizeReadiness(value) {
235
262
  const object = objectValue(value);
236
263
  if (!object || typeof object.ready !== 'boolean') return { state: 'unknown', ready: null, issues: null };
237
264
  const issues = asArray(object.issues);
265
+ const messageValue = firstValue(object, 'message', 'reason');
266
+ const nextActionValue = firstValue(object, 'next_action', 'nextAction');
238
267
  return {
239
268
  state: 'ready',
240
269
  ready: object.ready,
270
+ message: typeof messageValue === 'string' && messageValue.trim() ? messageValue.trim() : null,
271
+ next_action: typeof nextActionValue === 'string' && nextActionValue.trim() ? nextActionValue.trim() : null,
241
272
  issues: issues ? issues.map((issue) => ({
242
273
  code: text(firstValue(issue, 'code'), 'UNKNOWN'),
243
274
  path: text(firstValue(issue, 'path'), 'definition'),
@@ -262,14 +293,21 @@ function recordPayload(payload) {
262
293
  * Converts a canonical FoundationCatalogRecord (or a host projection of it)
263
294
  * without filling missing fields with guessed values.
264
295
  */
265
- export function normalizeObjectiveRecord(payload) {
266
- const source = recordPayload(payload);
296
+ export function normalizeObjectiveRecord(payload, expected) {
297
+ const readback = normalizeObjectiveReadback(payload, expected);
298
+ const payloadObject = objectValue(payload);
299
+ // An explicit readback envelope must pass its contract guard. Raw records
300
+ // remain accepted for non-HTTP host adapters and existing unit consumers.
301
+ if (payloadObject?.contract_version && !readback) return null;
302
+ const source = recordPayload(readback ?? payload);
267
303
  if (!source) return null;
268
304
  const definition = objectValue(source.definition) ?? source;
269
305
  const type = nonEmptyText(firstValue(definition, 'type')) ?? nonEmptyText(firstValue(source, 'type'));
270
306
  const id = nonEmptyText(firstValue(definition, 'id')) ?? nonEmptyText(firstValue(source, 'id'));
271
307
  const revision = normalizeRevision(firstValue(definition, 'revision', 'version')) ?? normalizeRevision(firstValue(source, 'revision', 'version'));
272
308
  if (!id || !revision) return null;
309
+ if (expected && (id !== expected.id
310
+ || (expected.revision !== undefined && expected.revision !== null && revision !== expected.revision))) return null;
273
311
  const criteriaValue = firstValue(definition, 'criteria');
274
312
  const criteria = asArray(criteriaValue);
275
313
  // A malformed criterion must invalidate the whole Objective readback. It
@@ -281,10 +319,16 @@ export function normalizeObjectiveRecord(payload) {
281
319
  const readiness = normalizeReadiness(firstValue(source, 'readiness', 'judgmentReadiness', 'judgment_readiness'));
282
320
  const constraintRefsValue = firstValue(source, 'constraintRefs', 'constraint_refs', 'constraints');
283
321
  const constraintRefs = asArray(constraintRefsValue);
322
+ const objectiveSource = objectValue(readback?.source)
323
+ ?? objectValue(firstValue(source, 'source', 'provenance', 'origin'))
324
+ ?? objectValue(firstValue(definition, 'source', 'provenance', 'origin'));
284
325
  const normalized = {
285
326
  id,
286
327
  type: type ?? 'objective',
287
328
  revision,
329
+ readback_status: readback?.status ?? null,
330
+ checked_at: readback?.checked_at ?? null,
331
+ readback_reason: readback?.reason ?? null,
288
332
  digest: nonEmptyText(firstValue(source, 'digest', 'hash')),
289
333
  meaning: nonEmptyText(firstValue(definition, 'meaning')),
290
334
  beneficiaryIds: asArray(firstValue(definition, 'beneficiaryIds', 'beneficiary_ids')),
@@ -301,6 +345,7 @@ export function normalizeObjectiveRecord(payload) {
301
345
  scope: objectValue(firstValue(definition, 'scope')),
302
346
  readiness,
303
347
  constraintRefs: constraintRefs ? constraintRefs.map((ref) => normalizeReference(ref, 'constraint')).filter(Boolean) : null,
348
+ source: objectiveSource,
304
349
  raw: source,
305
350
  definition,
306
351
  };
@@ -323,18 +368,23 @@ function collectionRecords(payload, keys) {
323
368
  export function normalizeObjectiveCollection(payload) {
324
369
  const recordsValue = collectionRecords(payload, ['records', 'objectives', 'items', 'data']);
325
370
  const explicitState = normalizeState(payloadValue(payload, 'state', 'status'), 'unknown');
371
+ const source = objectValue(payloadValue(payload, 'source', 'provenance', 'origin'));
372
+ const correctionGuidance = objectValue(payloadValue(payload, 'correction_guidance', 'correctionGuidance', 'correction'));
326
373
  const explicitFailure = explicitCollectionFailure(explicitState, 'records');
327
- if (explicitFailure) return explicitFailure;
374
+ if (explicitFailure) return { ...explicitFailure, source, correctionGuidance };
328
375
  if (recordsValue === null) {
329
- return { state: explicitState === 'empty' ? 'unknown' : explicitState, records: null, absence_confirmed: false };
376
+ return { state: explicitState === 'empty' ? 'unknown' : explicitState, records: null, absence_confirmed: false, source, correctionGuidance };
330
377
  }
331
- const records = recordsValue.map(normalizeObjectiveRecord);
378
+ // Do not pass Array#map's index as the readback identity expectation.
379
+ // `normalizeObjectiveRecord` accepts an optional expected `{ id, revision }`
380
+ // only for a specific read operation.
381
+ const records = recordsValue.map((record) => normalizeObjectiveRecord(record));
332
382
  if (records.some((record) => !record || record.type !== 'objective')) {
333
- return { state: 'unknown', records: null, absence_confirmed: false };
383
+ return { state: 'unknown', records: null, absence_confirmed: false, source, correctionGuidance };
334
384
  }
335
385
  const absenceConfirmed = payloadValue(payload, 'absence_confirmed', 'absenceConfirmed') === true;
336
386
  if (records.length === 0 && !absenceConfirmed) {
337
- return { state: 'unknown', records: null, absence_confirmed: false };
387
+ return { state: 'unknown', records: null, absence_confirmed: false, source, correctionGuidance };
338
388
  }
339
389
  const state = explicitState === 'permission_denied' || explicitState === 'api_unavailable' || explicitState === 'conflict'
340
390
  ? explicitState
@@ -343,28 +393,32 @@ export function normalizeObjectiveCollection(payload) {
343
393
  state,
344
394
  records,
345
395
  absence_confirmed: absenceConfirmed,
396
+ source,
397
+ correctionGuidance,
346
398
  };
347
399
  }
348
400
 
349
401
  export function normalizeReferenceCollection(payload, keys = ['refs', 'references', 'constraints', 'records', 'items', 'data']) {
350
402
  const recordsValue = collectionRecords(payload, keys);
351
403
  const explicitState = normalizeState(payloadValue(payload, 'state', 'status'), 'unknown');
404
+ const source = objectValue(payloadValue(payload, 'source', 'provenance', 'origin'));
352
405
  const explicitFailure = explicitCollectionFailure(explicitState, 'refs');
353
- if (explicitFailure) return explicitFailure;
354
- if (recordsValue === null) return { state: explicitState, refs: null, absence_confirmed: false };
406
+ if (explicitFailure) return { ...explicitFailure, source };
407
+ if (recordsValue === null) return { state: explicitState, refs: null, absence_confirmed: false, source };
355
408
  const refs = recordsValue.map((value) => normalizeReference(value));
356
409
  if (refs.some((ref) => !ref)) {
357
- return { state: 'unknown', refs: null, absence_confirmed: false };
410
+ return { state: 'unknown', refs: null, absence_confirmed: false, source };
358
411
  }
359
412
  const absenceConfirmed = payloadValue(payload, 'absence_confirmed', 'absenceConfirmed') === true;
360
413
  if (refs.length === 0 && !absenceConfirmed) {
361
- return { state: 'unknown', refs: null, absence_confirmed: false };
414
+ return { state: 'unknown', refs: null, absence_confirmed: false, source };
362
415
  }
363
416
  return {
364
417
  state: explicitState === 'permission_denied' || explicitState === 'api_unavailable' || explicitState === 'conflict'
365
418
  ? explicitState : refs.length ? 'ready' : 'empty',
366
419
  refs,
367
420
  absence_confirmed: absenceConfirmed,
421
+ source,
368
422
  };
369
423
  }
370
424
 
@@ -427,7 +481,10 @@ export function statusLabel(value) {
427
481
  }
428
482
 
429
483
  export function normalizeObjectiveError(error) {
430
- const code = optionalText(error?.code ?? error?.status ?? error?.name).trim().toLowerCase();
484
+ // Numeric HTTP status is interpreted below. Do not turn a 403 into the
485
+ // literal state "403", otherwise the status mapping is skipped and a
486
+ // canonical Foundation denial is rendered as a retryable error.
487
+ const code = optionalText(error?.code ?? (typeof error?.status === 'string' ? error.status : undefined)).trim().toLowerCase();
431
488
  const status = Number(error?.statusCode ?? error?.status);
432
489
  let state = normalizeState(code);
433
490
  if (state === UNKNOWN || !state) {
@@ -461,6 +518,12 @@ function statusNotice(state, callbacks = {}) {
461
518
  if (status === 'conflict' && normalized.currentRevision) {
462
519
  notice.append(makeElement('small', { text: `現在の版: ${normalized.currentRevision}。入力を保持したまま再読込してください。` }));
463
520
  }
521
+ if (normalized.checked_at) {
522
+ notice.append(
523
+ makeElement('small', { className: 'objective-editor-readback-meta', text: `正本の確認日時: ${normalized.checked_at}` }),
524
+ makeElement('small', { className: 'objective-editor-readback-meta', text: `出典: ${sourceSummary(normalized.readback_source)}` }),
525
+ );
526
+ }
464
527
  if (callbacks.onRetry) notice.append(makeButton('再読込', callbacks.onRetry, 'objective-editor-button-secondary'));
465
528
  return notice;
466
529
  }
@@ -605,6 +668,7 @@ function renderObjectiveList(root, state, callbacks) {
605
668
  }
606
669
  if (state.objectives.state === 'empty') {
607
670
  section.append(makeElement('div', { className: 'objective-editor-empty', text: '目的はまだ登録されていません。' }));
671
+ if (state.objectives.correctionGuidance) section.append(renderCorrectionGuidance(state.objectives.correctionGuidance));
608
672
  if (callbacks.onCreate) section.append(makeButton('目的を作成', callbacks.onCreate, 'objective-editor-button-primary'));
609
673
  return section;
610
674
  }
@@ -627,6 +691,23 @@ function renderObjectiveList(root, state, callbacks) {
627
691
  return section;
628
692
  }
629
693
 
694
+ function renderCorrectionGuidance(guidance) {
695
+ const section = makeElement('section', {
696
+ className: 'objective-editor-correction-guidance',
697
+ attrs: { 'aria-label': '次に確認すること' },
698
+ });
699
+ section.append(makeElement('strong', { text: '確認が必要な項目' }));
700
+ if (guidance?.message) section.append(makeElement('p', { text: guidance.message }));
701
+ const issues = Array.isArray(guidance?.issues) ? guidance.issues : [];
702
+ if (issues.length) {
703
+ const list = makeElement('ul', { className: 'objective-editor-issue-list' });
704
+ for (const issue of issues) list.append(makeElement('li', { text: `${text(issue?.path, '定義')}: ${text(issue?.message, '内容を確認してください。')}` }));
705
+ section.append(list);
706
+ }
707
+ if (guidance?.next_action) section.append(makeElement('p', { className: 'objective-editor-muted', text: `次の対応: ${guidance.next_action}` }));
708
+ return section;
709
+ }
710
+
630
711
  function renderReadiness(root, readiness) {
631
712
  const section = makeElement('section', { className: 'objective-editor-readiness', attrs: { 'aria-label': '判断利用可能性' } });
632
713
  const normalized = normalizeReadiness(readiness);
@@ -640,7 +721,14 @@ function renderReadiness(root, readiness) {
640
721
  const list = makeElement('ul', { className: 'objective-editor-issue-list' });
641
722
  for (const issue of normalized.issues) list.append(makeElement('li', { text: `${issue.path}: ${issue.message}` }));
642
723
  section.append(list);
643
- } else {
724
+ }
725
+ if (!normalized.ready && normalized.message) {
726
+ section.append(makeElement('p', { className: 'objective-editor-muted', text: normalized.message }));
727
+ }
728
+ if (!normalized.ready && normalized.next_action) {
729
+ section.append(makeElement('p', { className: 'objective-editor-muted', text: `次の対応: ${normalized.next_action}` }));
730
+ }
731
+ if (normalized.ready) {
644
732
  section.append(makeElement('p', { className: 'objective-editor-muted', text: '必要な定義・基準・参照が揃っています。観測値の存在は別途確認します。' }));
645
733
  }
646
734
  return section;
@@ -664,6 +752,7 @@ function renderConstraintRefs(root, state, callbacks) {
664
752
  const item = makeElement('li', { className: 'objective-editor-reference-item' });
665
753
  item.append(makeElement('span', { text: `${ref.id}@${ref.revision}` }));
666
754
  if (ref.meaning) item.append(makeElement('small', { text: ref.meaning }));
755
+ if (ref.source) item.append(makeElement('small', { className: 'objective-editor-reference-source', text: sourceSummary(ref.source) }));
667
756
  if (callbacks.canEdit && callbacks.constraintsEditable !== false && callbacks.onRemoveConstraintRef) item.append(makeButton('外す', () => callbacks.onRemoveConstraintRef(ref), 'objective-editor-button-danger'));
668
757
  list.append(item);
669
758
  }
@@ -745,6 +834,9 @@ function renderObjectiveForm(root, state, callbacks) {
745
834
  const section = makeElement('section', { className: 'objective-editor-panel', attrs: { 'aria-label': '目的を編集' } });
746
835
  const draft = state.editor.draft;
747
836
  if (!draft) {
837
+ if (FAILED_STATES.includes(state.editor.state)) {
838
+ section.append(statusNotice(state.editor, { onRetry: callbacks.onReload }));
839
+ }
748
840
  section.append(makeElement('div', { className: 'objective-editor-empty', text: '編集する目的を選択してください。' }));
749
841
  return section;
750
842
  }
@@ -753,6 +845,16 @@ function renderObjectiveForm(root, state, callbacks) {
753
845
  makeElement('div', { className: 'objective-editor-panel-heading', text: title }),
754
846
  makeElement('p', { className: 'objective-editor-muted', text: '保存すると新しい版になり、保存した版を読み戻して表示します。' }),
755
847
  );
848
+ if (state.selected) {
849
+ const status = OBJECTIVE_READBACK_STATUS_LABELS[state.selected.readback_status] ?? '未確認';
850
+ const readback = makeElement('div', { className: 'objective-editor-readback', attrs: { 'aria-label': '正本の確認情報' } });
851
+ readback.append(
852
+ makeElement('p', { className: 'objective-editor-muted', text: `正本の状態: ${status}` }),
853
+ makeElement('p', { className: 'objective-editor-muted', text: `確認日時: ${text(state.selected.checked_at)}` }),
854
+ makeElement('p', { className: 'objective-editor-muted', text: `出典: ${sourceSummary(state.selected.source)}` }),
855
+ );
856
+ section.append(readback);
857
+ }
756
858
  if (state.save.state !== 'idle' && state.save.state !== 'saving') section.append(statusNotice(state.save, {
757
859
  onRetry: ['conflict', 'saved_unverified'].includes(state.save.state) ? null : callbacks.onRetrySave,
758
860
  }));
@@ -932,6 +1034,12 @@ function workspaceStatusNotice(doc, value, { label, onRetry, retryLabel = '再
932
1034
  if (status === 'conflict' && normalized.currentRevision) {
933
1035
  body.append(makeElement('p', { text: `現在の版: ${normalized.currentRevision}。入力は残しています。現在の版を確かめてから直してください。` }));
934
1036
  }
1037
+ if (normalized.checked_at) {
1038
+ body.append(
1039
+ makeElement('p', { className: 'bb-objective-readback-meta', text: `正本の確認日時: ${normalized.checked_at}` }),
1040
+ makeElement('p', { className: 'bb-objective-readback-meta', text: `出典: ${sourceSummary(normalized.readback_source)}` }),
1041
+ );
1042
+ }
935
1043
  if (onRetry) body.append(workspaceButton(doc, { text: retryLabel, variant: 'quiet', onClick: onRetry }));
936
1044
  return workspaceNotice(doc, { label, text: body, tone });
937
1045
  }
@@ -979,7 +1087,7 @@ function renderObjectiveLedger(doc, state, callbacks) {
979
1087
  }
980
1088
  const loading = objectives.state === 'loading' || objectives.state === 'idle';
981
1089
  const records = loading ? [] : objectives.records ?? [];
982
- return workspaceLedger(doc, {
1090
+ const ledger = workspaceLedger(doc, {
983
1091
  className: 'bb-objective-ledger',
984
1092
  ariaLabel: '目的の一覧',
985
1093
  columns: OBJECTIVE_COLUMNS,
@@ -1000,6 +1108,12 @@ function renderObjectiveLedger(doc, state, callbacks) {
1000
1108
  };
1001
1109
  }),
1002
1110
  });
1111
+ if (!loading && objectives.state === 'empty' && objectives.correctionGuidance) {
1112
+ const wrapper = makeElement('div', { className: 'bb-objective-ledger-with-guidance' });
1113
+ wrapper.append(ledger, renderCorrectionGuidance(objectives.correctionGuidance));
1114
+ return wrapper;
1115
+ }
1116
+ return ledger;
1003
1117
  }
1004
1118
 
1005
1119
  /**
@@ -1068,10 +1182,14 @@ function constraintContent(doc, state, callbacks) {
1068
1182
  const item = makeElement('li');
1069
1183
  item.append(makeElement('code', { text: `${ref.id}@${ref.revision}` }));
1070
1184
  if (ref.meaning) item.append(makeElement('span', { text: ref.meaning }));
1185
+ if (ref.source) item.append(makeElement('small', { className: 'bb-objective-reference-source', text: sourceSummary(ref.source) }));
1071
1186
  list.append(item);
1072
1187
  }
1073
1188
  content.push(list);
1074
1189
  }
1190
+ if (state.constraints.source) {
1191
+ content.push(makeElement('p', { className: 'bb-objective-reference-source', text: `参照元: ${sourceSummary(state.constraints.source)}` }));
1192
+ }
1075
1193
  content.push(makeElement('p', {
1076
1194
  className: 'bb-objective-note',
1077
1195
  text: callbacks.constraintsEditable === false || !callbacks.canEdit
@@ -1095,6 +1213,8 @@ function readinessContent(doc, readiness) {
1095
1213
  for (const issue of normalized.issues) list.append(makeElement('li', { text: `${issue.path}: ${issue.message}` }));
1096
1214
  content.push(list);
1097
1215
  }
1216
+ if (normalized.message) content.push(makeElement('p', { className: 'bb-objective-note', text: normalized.message }));
1217
+ if (normalized.next_action) content.push(makeElement('p', { className: 'bb-objective-note', text: `次の対応: ${normalized.next_action}` }));
1098
1218
  return content;
1099
1219
  }
1100
1220
 
@@ -1115,6 +1235,9 @@ function renderObjectiveDetail(doc, state, callbacks) {
1115
1235
  ['責任者', objective.accountableId],
1116
1236
  ['採用状態', objective.adoptionState ? (STATUS_LABELS[objective.adoptionState] ?? objective.adoptionState) : null],
1117
1237
  ['評価期間', periodText(objective.evaluationPeriod)],
1238
+ ['正本の状態', OBJECTIVE_READBACK_STATUS_LABELS[objective.readback_status] ?? '未確認'],
1239
+ ['確認日時', objective.checked_at],
1240
+ ['出典', sourceSummary(objective.source)],
1118
1241
  ]),
1119
1242
  }));
1120
1243
  parts.push(workspaceRailBlock(doc, { title: `評価基準${objective.criteria ? ` ${objective.criteria.length}件` : ''}`, className: 'bb-objective-criteria-block', content: criteriaContent(doc, objective.criteria) }));
@@ -1237,6 +1360,10 @@ export function createObjectiveEditorController(options = {}) {
1237
1360
  view: options.initialView ?? 'list',
1238
1361
  connection: port ? 'ready' : 'api_unavailable',
1239
1362
  connectionMessage: port ? null : '目的を読み書きする経路が、この画面に渡されていません。',
1363
+ // A project catalog grant is the initial write authority. A canonical
1364
+ // Foundation 403 is stronger evidence for this editor and must make the
1365
+ // currently mounted controls read-only until the host remounts it.
1366
+ permissionDenied: false,
1240
1367
  objectives: { state: 'idle', records: null, absence_confirmed: false },
1241
1368
  selected: null,
1242
1369
  readiness: { state: 'unknown', ready: null, issues: null },
@@ -1261,12 +1388,16 @@ export function createObjectiveEditorController(options = {}) {
1261
1388
  let renderVersion = 0;
1262
1389
  let activeSave = null;
1263
1390
 
1391
+ function effectiveCanEdit() {
1392
+ return canEdit && !state.permissionDenied;
1393
+ }
1394
+
1264
1395
  function render() {
1265
1396
  if (!root) return null;
1266
1397
  const currentRender = ++renderVersion;
1267
1398
  const currentEditor = editorVersion;
1268
1399
  const callbacks = {
1269
- canEdit,
1400
+ canEdit: effectiveCanEdit(),
1270
1401
  saving: activeSave !== null,
1271
1402
  constraintsEditable,
1272
1403
  storyLinks,
@@ -1326,9 +1457,12 @@ export function createObjectiveEditorController(options = {}) {
1326
1457
  state.connection = state.objectives.state === 'ready' || state.objectives.state === 'empty' ? 'ready' : state.objectives.state;
1327
1458
  } catch (error) {
1328
1459
  if (request !== listRequest) return state.objectives;
1329
- state.objectives = { state: normalizeObjectiveError(error).state, records: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
1460
+ const normalized = normalizeObjectiveError(error);
1461
+ state.permissionDenied ||= normalized.state === 'permission_denied';
1462
+ state.objectives = { state: normalized.state, records: null, absence_confirmed: false, ...normalized };
1330
1463
  state.connection = state.objectives.state;
1331
1464
  }
1465
+ if (state.objectives.state === 'permission_denied') state.permissionDenied = true;
1332
1466
  render();
1333
1467
  // As on the organization screens, the workspace opens with the first row selected.
1334
1468
  const first = state.objectives.records?.[0];
@@ -1365,7 +1499,9 @@ export function createObjectiveEditorController(options = {}) {
1365
1499
  }
1366
1500
  } catch (error) {
1367
1501
  if (!current()) return state.constraints;
1368
- state.constraints = { state: normalizeObjectiveError(error).state, refs: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
1502
+ const normalized = normalizeObjectiveError(error);
1503
+ state.permissionDenied ||= normalized.state === 'permission_denied';
1504
+ state.constraints = { state: normalized.state, refs: null, absence_confirmed: false, ...normalized };
1369
1505
  }
1370
1506
  render();
1371
1507
  return state.constraints;
@@ -1389,7 +1525,9 @@ export function createObjectiveEditorController(options = {}) {
1389
1525
  state.readiness = normalizeReadiness(payload);
1390
1526
  } catch (error) {
1391
1527
  if (!current()) return state.readiness;
1392
- state.readiness = { state: normalizeObjectiveError(error).state, ready: null, issues: null, ...normalizeObjectiveError(error) };
1528
+ const normalized = normalizeObjectiveError(error);
1529
+ state.permissionDenied ||= normalized.state === 'permission_denied';
1530
+ state.readiness = { state: normalized.state, ready: null, issues: null, ...normalized };
1393
1531
  }
1394
1532
  if (state.selected) state.selected.readiness = state.readiness;
1395
1533
  render();
@@ -1425,7 +1563,25 @@ export function createObjectiveEditorController(options = {}) {
1425
1563
  try {
1426
1564
  const payload = await method(id, context, revision);
1427
1565
  if (version !== selectionVersion) return null;
1428
- const objective = normalizeObjectiveRecord(payload);
1566
+ const expected = { id, revision };
1567
+ const readback = normalizeObjectiveReadback(payload, expected);
1568
+ if (readback && readback.status !== 'ready') {
1569
+ const readbackState = readback.status === 'error' ? 'error_retryable' : 'unknown';
1570
+ const message = OBJECTIVE_READBACK_REASON_MESSAGES[readback.reason?.code] ?? OBJECTIVE_READBACK_STATUS_LABELS[readback.status];
1571
+ state.editor = {
1572
+ ...state.editor,
1573
+ state: readbackState,
1574
+ message,
1575
+ readback_status: readback.status,
1576
+ checked_at: readback.checked_at,
1577
+ readback_source: readback.source,
1578
+ readback_reason: readback.reason,
1579
+ };
1580
+ state.connection = readbackState;
1581
+ render();
1582
+ return null;
1583
+ }
1584
+ const objective = normalizeObjectiveRecord(payload, expected);
1429
1585
  if (!objective) {
1430
1586
  state.editor = { ...state.editor, state: 'missing', message: '指定した目的を保存先から確認できません。' };
1431
1587
  state.connection = 'missing';
@@ -1444,6 +1600,7 @@ export function createObjectiveEditorController(options = {}) {
1444
1600
  } catch (error) {
1445
1601
  if (version !== selectionVersion) return null;
1446
1602
  const normalized = normalizeObjectiveError(error);
1603
+ state.permissionDenied ||= normalized.state === 'permission_denied';
1447
1604
  state.editor = { ...state.editor, state: normalized.state, message: normalized.message, error: normalized.error };
1448
1605
  state.connection = normalized.state;
1449
1606
  render();
@@ -1452,6 +1609,7 @@ export function createObjectiveEditorController(options = {}) {
1452
1609
  }
1453
1610
 
1454
1611
  function beginCreate() {
1612
+ if (!effectiveCanEdit()) return;
1455
1613
  // A repeated click on New must not throw away an in-progress draft.
1456
1614
  if (state.editor.mode === 'create' && state.railMode === 'form') return;
1457
1615
  selectionVersion += 1;
@@ -1475,6 +1633,7 @@ export function createObjectiveEditorController(options = {}) {
1475
1633
 
1476
1634
  /** Workspace layout: open the edit form for the selected objective in the rail. */
1477
1635
  function beginEdit() {
1636
+ if (!effectiveCanEdit()) return;
1478
1637
  if (!state.selected) return;
1479
1638
  if (state.railMode === 'form' && state.editor.mode === 'edit') return;
1480
1639
  editorVersion += 1;
@@ -1519,7 +1678,7 @@ export function createObjectiveEditorController(options = {}) {
1519
1678
  }
1520
1679
 
1521
1680
  function addConstraintRef(reference) {
1522
- if (!canEdit || !constraintsEditable || activeSave) return;
1681
+ if (!effectiveCanEdit() || !constraintsEditable || activeSave) return;
1523
1682
  const normalized = normalizeReference(reference, 'constraint');
1524
1683
  if (!normalized || normalized.type !== 'constraint') return;
1525
1684
  const refs = state.editor.constraintRefs ?? [];
@@ -1530,7 +1689,7 @@ export function createObjectiveEditorController(options = {}) {
1530
1689
  }
1531
1690
 
1532
1691
  function removeConstraintRef(reference) {
1533
- if (!canEdit || !constraintsEditable || activeSave) return;
1692
+ if (!effectiveCanEdit() || !constraintsEditable || activeSave) return;
1534
1693
  state.editor.constraintRefs = (state.editor.constraintRefs ?? []).filter((item) => !(item.id === reference.id && item.revision === reference.revision));
1535
1694
  state.editor.constraintRefsChanged = true;
1536
1695
  render();
@@ -1538,7 +1697,7 @@ export function createObjectiveEditorController(options = {}) {
1538
1697
 
1539
1698
  async function saveObjective(definition) {
1540
1699
  if (!definition) return null;
1541
- if (!canEdit) return { state: 'permission_denied', message: 'この画面では目的を保存できません。' };
1700
+ if (!effectiveCanEdit()) return { state: 'permission_denied', message: 'この画面では目的を保存できません。' };
1542
1701
  if (activeSave) return { state: 'saving', action: activeSave.mode };
1543
1702
  // Once a mutation succeeded, repeating it is unsafe until the owner has
1544
1703
  // reloaded and selected the canonical revision again.
@@ -1636,6 +1795,7 @@ export function createObjectiveEditorController(options = {}) {
1636
1795
  return result;
1637
1796
  } catch (error) {
1638
1797
  const normalized = normalizeObjectiveError(error);
1798
+ state.permissionDenied ||= normalized.state === 'permission_denied';
1639
1799
  return publish(mutationCompleted
1640
1800
  ? { state: 'saved_unverified', message: '目的は保存されましたが、保存結果を確かめられません。一覧を再読込して確かめてください。', mutation, error: normalized.error, draft: definition }
1641
1801
  : { state: normalized.state, message: normalized.message, currentRevision: normalized.currentRevision, error: normalized.error, draft: definition });
@@ -1658,7 +1818,9 @@ export function createObjectiveEditorController(options = {}) {
1658
1818
  try {
1659
1819
  state.story = { ...normalizeStoryObjectiveLinks(await method(storyId, context)), storyId };
1660
1820
  } catch (error) {
1661
- state.story = { state: normalizeObjectiveError(error).state, storyId, links: null, absence_confirmed: false, ...normalizeObjectiveError(error) };
1821
+ const normalized = normalizeObjectiveError(error);
1822
+ state.permissionDenied ||= normalized.state === 'permission_denied';
1823
+ state.story = { state: normalized.state, storyId, links: null, absence_confirmed: false, ...normalized };
1662
1824
  }
1663
1825
  render();
1664
1826
  return state.story;