@objectstack/objectql 7.0.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -390,6 +390,14 @@ var SchemaRegistry = class {
390
390
  if (collection.has(storageKey)) {
391
391
  this.log(`[Registry] Overwriting ${type}: ${storageKey}`);
392
392
  }
393
+ if (packageId && collection.has(baseName)) {
394
+ const dbOnly = collection.get(baseName);
395
+ if (dbOnly && !dbOnly._packageId) {
396
+ console.warn(
397
+ `[Registry] Collision: ${type}/${baseName} ships from package "${packageId}" but a runtime-authored row with the same name already exists in sys_metadata. The runtime row will shadow the package value (ADR-0005 overlay precedence). Rename one, or delete the sys_metadata row if the package value should win.`
398
+ );
399
+ }
400
+ }
393
401
  collection.set(storageKey, item);
394
402
  this.log(`[Registry] Registered ${type}: ${storageKey}`);
395
403
  }
@@ -446,7 +454,9 @@ var SchemaRegistry = class {
446
454
  const direct = collection.get(name);
447
455
  if (direct) return direct;
448
456
  for (const [key, item] of collection) {
449
- if (key.endsWith(`:${name}`)) return item;
457
+ if (key.endsWith(`:${name}`)) {
458
+ return item;
459
+ }
450
460
  }
451
461
  return void 0;
452
462
  }
@@ -626,6 +636,12 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from "@objectstack/spec/shared
626
636
  var OVERLAY_ALLOWED_TYPES = new Set(
627
637
  DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)
628
638
  );
639
+ var STATIC_REGISTRY_TYPES = new Set(
640
+ DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)
641
+ );
642
+ var RUNTIME_CREATE_ALLOWED_TYPES = new Set(
643
+ DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowRuntimeCreate).map((e) => e.type)
644
+ );
629
645
  var _envWritableMetadataTypes = null;
630
646
  function envWritableMetadataTypes() {
631
647
  if (_envWritableMetadataTypes !== null) return _envWritableMetadataTypes;
@@ -675,11 +691,16 @@ var SysMetadataRepository = class {
675
691
  /**
676
692
  * Read the current overlay row. Returns null if no row exists —
677
693
  * callers (e.g. LayeredRepository) fall through to lower layers.
694
+ *
695
+ * `opts.state` selects which lifecycle row to read: defaults to the
696
+ * live published row (`'active'`). Pass `'draft'` to read the pending
697
+ * unpublished revision (if any).
678
698
  */
679
- async get(ref) {
699
+ async get(ref, opts) {
680
700
  this.assertOpen();
701
+ const state = opts?.state ?? "active";
681
702
  const row = await this.engine.findOne("sys_metadata", {
682
- where: this.whereFor(ref)
703
+ where: this.whereFor(ref, state)
683
704
  });
684
705
  if (!row) return null;
685
706
  return this.rowToItem(ref, row);
@@ -722,12 +743,13 @@ var SysMetadataRepository = class {
722
743
  }
723
744
  async put(ref, spec, opts) {
724
745
  this.assertOpen();
725
- this.assertAllowed(ref.type);
746
+ this.assertAllowed(ref.type, opts.intent);
747
+ const state = opts.state ?? "active";
726
748
  const body = spec ?? {};
727
749
  const hash = hashSpec(body);
728
750
  const result = await this.withTxn(async (ctx) => {
729
751
  const existing = await this.engine.findOne("sys_metadata", {
730
- where: this.whereFor(ref),
752
+ where: this.whereFor(ref, state),
731
753
  context: ctx
732
754
  });
733
755
  const existingHash = existing?.checksum ?? null;
@@ -739,7 +761,8 @@ var SysMetadataRepository = class {
739
761
  return { skipped: true, version: hash, seq: item2.seq, item: item2 };
740
762
  }
741
763
  const now = (/* @__PURE__ */ new Date()).toISOString();
742
- const op = existing ? "update" : "create";
764
+ const baseOp = existing ? "update" : "create";
765
+ const op = opts.opType ?? baseOp;
743
766
  const version = await this.nextItemVersion(ref, ctx);
744
767
  const eventSeq = await this.nextEventSeq(ctx);
745
768
  const parentRowData = {
@@ -748,7 +771,7 @@ var SysMetadataRepository = class {
748
771
  organization_id: this.organizationId,
749
772
  metadata: JSON.stringify(body),
750
773
  checksum: hash,
751
- state: "active",
774
+ state,
752
775
  version,
753
776
  updated_at: now
754
777
  };
@@ -814,25 +837,28 @@ var SysMetadataRepository = class {
814
837
  return { version: result.version, seq: result.seq, item: result.item };
815
838
  }
816
839
  this.seqCounter = result.seq;
817
- this.broadcast({
818
- seq: result.seq,
819
- op: result.op,
820
- ref: this.fullRef(ref),
821
- hash: result.version,
822
- parentHash: result.existingHash,
823
- actor: result.actor,
824
- message: result.message,
825
- ts: result.now,
826
- source: result.source
827
- });
840
+ if (state === "active") {
841
+ this.broadcast({
842
+ seq: result.seq,
843
+ op: result.op,
844
+ ref: this.fullRef(ref),
845
+ hash: result.version,
846
+ parentHash: result.existingHash,
847
+ actor: result.actor,
848
+ message: result.message,
849
+ ts: result.now,
850
+ source: result.source
851
+ });
852
+ }
828
853
  return { version: result.version, seq: result.seq, item: result.item };
829
854
  }
830
855
  async delete(ref, opts) {
831
856
  this.assertOpen();
832
- this.assertAllowed(ref.type);
857
+ this.assertAllowed(ref.type, opts.intent);
858
+ const state = opts.state ?? "active";
833
859
  const result = await this.withTxn(async (ctx) => {
834
860
  const existing = await this.engine.findOne("sys_metadata", {
835
- where: this.whereFor(ref),
861
+ where: this.whereFor(ref, state),
836
862
  context: ctx
837
863
  });
838
864
  if (!existing) {
@@ -849,32 +875,38 @@ var SysMetadataRepository = class {
849
875
  );
850
876
  }
851
877
  const now = (/* @__PURE__ */ new Date()).toISOString();
852
- const version = await this.nextItemVersion(ref, ctx);
853
- const eventSeq = await this.nextEventSeq(ctx);
878
+ let version = 0;
879
+ let eventSeq = 0;
880
+ if (state === "active") {
881
+ version = await this.nextItemVersion(ref, ctx);
882
+ eventSeq = await this.nextEventSeq(ctx);
883
+ }
854
884
  await this.engine.delete("sys_metadata", {
855
885
  where: { id: existingId },
856
886
  context: ctx
857
887
  });
858
- await this.engine.insert(
859
- this.historyTable,
860
- {
861
- id: this.uuid(),
862
- event_seq: eventSeq,
863
- type: ref.type,
864
- name: ref.name,
865
- version,
866
- operation_type: "delete",
867
- metadata: null,
868
- checksum: null,
869
- previous_checksum: existingHash,
870
- change_note: opts.message,
871
- source: opts.source ?? "sys-metadata-repo",
872
- organization_id: this.organizationId,
873
- recorded_by: opts.actor,
874
- recorded_at: now
875
- },
876
- { context: ctx }
877
- );
888
+ if (state === "active") {
889
+ await this.engine.insert(
890
+ this.historyTable,
891
+ {
892
+ id: this.uuid(),
893
+ event_seq: eventSeq,
894
+ type: ref.type,
895
+ name: ref.name,
896
+ version,
897
+ operation_type: "delete",
898
+ metadata: null,
899
+ checksum: null,
900
+ previous_checksum: existingHash,
901
+ change_note: opts.message,
902
+ source: opts.source ?? "sys-metadata-repo",
903
+ organization_id: this.organizationId,
904
+ recorded_by: opts.actor,
905
+ recorded_at: now
906
+ },
907
+ { context: ctx }
908
+ );
909
+ }
878
910
  return {
879
911
  eventSeq,
880
912
  existingHash,
@@ -884,20 +916,117 @@ var SysMetadataRepository = class {
884
916
  actor: opts.actor
885
917
  };
886
918
  });
887
- this.seqCounter = result.eventSeq;
888
- this.broadcast({
889
- seq: result.eventSeq,
890
- op: "delete",
891
- ref: this.fullRef(ref),
892
- hash: null,
893
- parentHash: result.existingHash,
894
- actor: result.actor,
895
- message: result.message,
896
- ts: result.now,
897
- source: result.source
898
- });
919
+ if (state === "active") {
920
+ this.seqCounter = result.eventSeq;
921
+ this.broadcast({
922
+ seq: result.eventSeq,
923
+ op: "delete",
924
+ ref: this.fullRef(ref),
925
+ hash: null,
926
+ parentHash: result.existingHash,
927
+ actor: result.actor,
928
+ message: result.message,
929
+ ts: result.now,
930
+ source: result.source
931
+ });
932
+ }
899
933
  return { seq: result.eventSeq };
900
934
  }
935
+ /**
936
+ * Promote the pending draft row for `ref` into the live (`active`)
937
+ * overlay. Atomic: reads the draft inside the same transaction, runs
938
+ * the canonical `put` to upsert the active row (which appends a
939
+ * history event with `operation_type='publish'`), then deletes the
940
+ * draft row.
941
+ *
942
+ * Errors if no draft exists (callers should 404). The active row's
943
+ * `parentVersion` is computed from the current active hash so this
944
+ * also surfaces optimistic-lock conflicts when something else has
945
+ * published in between (e.g. another admin reverted to an older
946
+ * version since the draft was authored).
947
+ */
948
+ async promoteDraft(ref, opts) {
949
+ this.assertOpen();
950
+ const draft = await this.get(ref, { state: "draft" });
951
+ if (!draft) {
952
+ const err = new Error(
953
+ `[no_draft] No pending draft exists for ${ref.type}/${ref.name} \u2014 nothing to publish.`
954
+ );
955
+ err.code = "no_draft";
956
+ err.status = 404;
957
+ throw err;
958
+ }
959
+ const currentActive = await this.get(ref, { state: "active" });
960
+ const result = await this.put(ref, draft.body, {
961
+ parentVersion: currentActive?.hash ?? null,
962
+ actor: opts.actor,
963
+ source: opts.source ?? "sys-metadata-repo.publish",
964
+ message: opts.message ?? `publish draft (hash ${draft.hash})`,
965
+ intent: opts.intent ?? "override-artifact",
966
+ state: "active",
967
+ opType: "publish"
968
+ });
969
+ try {
970
+ await this.delete(ref, {
971
+ parentVersion: draft.hash,
972
+ actor: opts.actor,
973
+ source: opts.source ?? "sys-metadata-repo.publish",
974
+ intent: opts.intent ?? "override-artifact",
975
+ state: "draft"
976
+ });
977
+ } catch {
978
+ }
979
+ return result;
980
+ }
981
+ /**
982
+ * Restore the body recorded in history at `targetVersion` (per-org
983
+ * lineage counter) as the new active row. Writes a history event
984
+ * with `operation_type='revert'` so the audit trail captures the
985
+ * intent. Does NOT touch any draft row.
986
+ *
987
+ * Throws `[version_not_found]` (404) if the target version row is
988
+ * missing or is a delete tombstone (no body to restore).
989
+ */
990
+ async restoreVersion(ref, targetVersion, opts) {
991
+ this.assertOpen();
992
+ const full = this.fullRef(ref);
993
+ const row = await this.engine.findOne(this.historyTable, {
994
+ where: {
995
+ organization_id: this.organizationId,
996
+ type: full.type,
997
+ name: full.name,
998
+ version: targetVersion
999
+ }
1000
+ });
1001
+ if (!row) {
1002
+ const err = new Error(
1003
+ `[version_not_found] No history row at version ${targetVersion} for ${ref.type}/${ref.name}.`
1004
+ );
1005
+ err.code = "version_not_found";
1006
+ err.status = 404;
1007
+ throw err;
1008
+ }
1009
+ const raw = row.metadata;
1010
+ if (raw === null || raw === void 0) {
1011
+ const err = new Error(
1012
+ `[version_not_restorable] Version ${targetVersion} for ${ref.type}/${ref.name} is a delete tombstone \u2014 nothing to restore.`
1013
+ );
1014
+ err.code = "version_not_restorable";
1015
+ err.status = 409;
1016
+ throw err;
1017
+ }
1018
+ const body = typeof raw === "string" ? JSON.parse(raw) : raw;
1019
+ const currentActive = await this.get(ref, { state: "active" });
1020
+ return this.put(ref, body, {
1021
+ parentVersion: currentActive?.hash ?? null,
1022
+ actor: opts.actor,
1023
+ source: opts.source ?? "sys-metadata-repo.revert",
1024
+ message: opts.message ?? `revert to version ${targetVersion}`,
1025
+ intent: opts.intent ?? "override-artifact",
1026
+ state: "active",
1027
+ opType: "revert"
1028
+ });
1029
+ }
901
1030
  async *list(filter) {
902
1031
  this.assertOpen();
903
1032
  const where = {
@@ -952,6 +1081,7 @@ var SysMetadataRepository = class {
952
1081
  ref: full,
953
1082
  hash: row.checksum ?? null,
954
1083
  parentHash: row.previous_checksum ?? null,
1084
+ version: typeof row.version === "number" ? row.version : void 0,
955
1085
  actor: row.recorded_by ?? "unknown",
956
1086
  message: row.change_note ?? void 0,
957
1087
  ts: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
@@ -1033,29 +1163,52 @@ var SysMetadataRepository = class {
1033
1163
  assertOpen() {
1034
1164
  if (this.closed) throw new Error("SysMetadataRepository is closed");
1035
1165
  }
1036
- assertAllowed(type) {
1166
+ /**
1167
+ * Defense-in-depth authorization gate.
1168
+ *
1169
+ * `intent` defaults to `'override-artifact'` (the historical strict
1170
+ * behavior). The protocol layer passes `'runtime-only'` after it has
1171
+ * verified — via the schema registry — that no artifact item exists
1172
+ * at `(type, name)`. In that case we accept types with
1173
+ * `allowRuntimeCreate: true`, even when `allowOrgOverride` is false.
1174
+ *
1175
+ * The env-var escape hatch (`OBJECTSTACK_METADATA_WRITABLE`) still
1176
+ * applies to BOTH intents, so operators can opt into artifact
1177
+ * overrides at runtime for emergency fixes.
1178
+ */
1179
+ assertAllowed(type, intent = "override-artifact") {
1037
1180
  const singular = PLURAL_TO_SINGULAR[type] ?? type;
1038
1181
  const allowedByRegistry = OVERLAY_ALLOWED_TYPES.has(singular) || OVERLAY_ALLOWED_TYPES.has(type);
1039
1182
  if (allowedByRegistry) return;
1183
+ if (intent === "runtime-only") {
1184
+ if (RUNTIME_CREATE_ALLOWED_TYPES.has(singular) || RUNTIME_CREATE_ALLOWED_TYPES.has(type)) {
1185
+ return;
1186
+ }
1187
+ if (!STATIC_REGISTRY_TYPES.has(singular) && !STATIC_REGISTRY_TYPES.has(type)) {
1188
+ return;
1189
+ }
1190
+ }
1040
1191
  const env = envWritableMetadataTypes();
1041
1192
  if (env.has(singular) || env.has(type)) return;
1042
1193
  const allowed = [
1043
1194
  ...OVERLAY_ALLOWED_TYPES,
1044
1195
  ...envWritableMetadataTypes()
1045
1196
  ];
1197
+ const code = intent === "runtime-only" ? "not_creatable" : "not_overridable";
1198
+ const detail = intent === "runtime-only" ? `'${type}' has neither allowOrgOverride nor allowRuntimeCreate in the registry. ` : `'${type}' is not allowOrgOverride in the registry. `;
1046
1199
  const err = new Error(
1047
- `[not_overridable] '${type}' is not allowOrgOverride in the registry. Allowed: ${Array.from(new Set(allowed)).join(", ") || "(none)"}. Set OBJECTSTACK_METADATA_WRITABLE to enable additional types at runtime.`
1200
+ `[${code}] ${detail}Overlay-allowed: ${Array.from(new Set(allowed)).join(", ") || "(none)"}. Set OBJECTSTACK_METADATA_WRITABLE to enable additional types at runtime.`
1048
1201
  );
1049
- err.code = "not_overridable";
1202
+ err.code = code;
1050
1203
  err.status = 403;
1051
1204
  throw err;
1052
1205
  }
1053
- whereFor(ref) {
1206
+ whereFor(ref, state = "active") {
1054
1207
  return {
1055
1208
  type: ref.type,
1056
1209
  name: ref.name,
1057
1210
  organization_id: this.organizationId,
1058
- state: "active"
1211
+ state
1059
1212
  };
1060
1213
  }
1061
1214
  fullRef(ref) {
@@ -1151,37 +1304,64 @@ var SysMetadataRepository = class {
1151
1304
 
1152
1305
  // src/protocol.ts
1153
1306
  import { ConflictError as ConflictError2 } from "@objectstack/metadata-core";
1154
- import { parseFilterAST, isFilterAST, ObjectSchema as ObjectSchema2, FieldSchema, HookSchema } from "@objectstack/spec/data";
1155
- import { PLURAL_TO_SINGULAR as PLURAL_TO_SINGULAR2, SINGULAR_TO_PLURAL as SINGULAR_TO_PLURAL2 } from "@objectstack/spec/shared";
1156
- import { ListViewSchema, FormViewSchema, DashboardSchema, AppSchema as AppSchema2, PageSchema, ReportSchema, ActionSchema } from "@objectstack/spec/ui";
1157
- import { RoleSchema } from "@objectstack/spec/identity";
1158
- import { PermissionSetSchema } from "@objectstack/spec/security";
1159
- import { EmailTemplateSchema, JobSchema, METADATA_FORM_REGISTRY } from "@objectstack/spec/system";
1160
- import { ToolSchema, SkillSchema, AgentSchema } from "@objectstack/spec/ai";
1161
- import { FlowSchema, WorkflowRuleSchema, ApprovalProcessSchema } from "@objectstack/spec/automation";
1162
- import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2 } from "@objectstack/spec/kernel";
1307
+ import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
1308
+ import { PLURAL_TO_SINGULAR as PLURAL_TO_SINGULAR3, SINGULAR_TO_PLURAL as SINGULAR_TO_PLURAL2 } from "@objectstack/spec/shared";
1309
+ import { METADATA_FORM_REGISTRY } from "@objectstack/spec/system";
1310
+ import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2, getMetadataTypeSchema as getMetadataTypeSchema2 } from "@objectstack/spec/kernel";
1311
+ import {
1312
+ extractProtection,
1313
+ evaluateLockForWrite,
1314
+ evaluateLockForDelete,
1315
+ resolveLockState
1316
+ } from "@objectstack/spec/kernel";
1163
1317
  import { z } from "zod";
1164
- var TYPE_TO_SCHEMA = {
1165
- object: ObjectSchema2,
1166
- field: FieldSchema,
1167
- dashboard: DashboardSchema,
1168
- app: AppSchema2,
1169
- page: PageSchema,
1170
- report: ReportSchema,
1171
- action: ActionSchema,
1172
- role: RoleSchema,
1173
- permission: PermissionSetSchema,
1174
- profile: PermissionSetSchema,
1175
- email_template: EmailTemplateSchema,
1176
- tool: ToolSchema,
1177
- skill: SkillSchema,
1178
- agent: AgentSchema,
1179
- flow: FlowSchema,
1180
- workflow: WorkflowRuleSchema,
1181
- approval: ApprovalProcessSchema,
1182
- job: JobSchema,
1183
- hook: HookSchema
1184
- };
1318
+
1319
+ // src/metadata-diagnostics.ts
1320
+ import { getMetadataTypeSchema } from "@objectstack/spec/kernel";
1321
+ import { PLURAL_TO_SINGULAR as PLURAL_TO_SINGULAR2 } from "@objectstack/spec/shared";
1322
+ function computeMetadataDiagnostics(type, item) {
1323
+ const singular = PLURAL_TO_SINGULAR2[type] ?? type;
1324
+ const schema = getMetadataTypeSchema(singular);
1325
+ if (!schema) return void 0;
1326
+ if (item === null || item === void 0 || typeof item !== "object") {
1327
+ return {
1328
+ valid: false,
1329
+ errors: [{
1330
+ path: "",
1331
+ message: "Metadata document must be a non-null object",
1332
+ code: "invalid_type"
1333
+ }]
1334
+ };
1335
+ }
1336
+ const candidate = "_diagnostics" in item ? stripDiagnostics(item) : item;
1337
+ const parsed = schema.safeParse(candidate);
1338
+ if (parsed.success) {
1339
+ return { valid: true };
1340
+ }
1341
+ const errors = parsed.error.issues.map((issue) => ({
1342
+ path: issue.path.map(String).join("."),
1343
+ message: issue.message,
1344
+ code: issue.code
1345
+ }));
1346
+ return { valid: false, errors };
1347
+ }
1348
+ function stripDiagnostics(item) {
1349
+ const { _diagnostics: _drop, ...rest } = item;
1350
+ void _drop;
1351
+ return rest;
1352
+ }
1353
+ function decorateMetadataItem(type, item) {
1354
+ if (!item || typeof item !== "object") return item;
1355
+ const diagnostics = computeMetadataDiagnostics(type, item);
1356
+ if (!diagnostics) return item;
1357
+ return { ...item, _diagnostics: diagnostics };
1358
+ }
1359
+ function decorateMetadataItems(type, items) {
1360
+ if (!Array.isArray(items)) return items;
1361
+ return items.map((item) => decorateMetadataItem(type, item));
1362
+ }
1363
+
1364
+ // src/protocol.ts
1185
1365
  var TYPE_TO_FORM = METADATA_FORM_REGISTRY;
1186
1366
  var _jsonSchemaCache = /* @__PURE__ */ new WeakMap();
1187
1367
  function toJsonSchemaSafe(schema) {
@@ -1211,9 +1391,17 @@ var HAND_CRAFTED_SCHEMAS = {
1211
1391
  abstract: { type: "boolean", default: false },
1212
1392
  datasource: { type: "string" },
1213
1393
  fields: {
1214
- type: "array",
1215
- default: [],
1216
- items: {
1394
+ // Canonical Object.fields is a name-keyed map
1395
+ // (Record<string, FieldDefinition>) — insertion order is
1396
+ // display order. The SchemaForm engine recognises
1397
+ // `additionalProperties` as a Record and dispatches to
1398
+ // the `record` form-field renderer (ADR-0007). The form
1399
+ // layout in `object.form.ts` declares `type: 'record'`
1400
+ // so the inner `additionalProperties` schema is used to
1401
+ // shape each value.
1402
+ type: "object",
1403
+ default: {},
1404
+ additionalProperties: {
1217
1405
  type: "object",
1218
1406
  properties: {
1219
1407
  name: { type: "string" },
@@ -1224,7 +1412,7 @@ var HAND_CRAFTED_SCHEMAS = {
1224
1412
  defaultValue: {},
1225
1413
  description: { type: "string" }
1226
1414
  },
1227
- required: ["name", "type"]
1415
+ required: ["type"]
1228
1416
  }
1229
1417
  },
1230
1418
  capabilities: { type: "object", additionalProperties: true }
@@ -1369,19 +1557,22 @@ var HAND_CRAFTED_SCHEMAS = {
1369
1557
  additionalProperties: true
1370
1558
  }
1371
1559
  };
1372
- var FORM_VIEW_TYPES = /* @__PURE__ */ new Set(["simple", "tabbed", "wizard", "split", "drawer", "modal"]);
1373
- function resolveOverlaySchema(type, item) {
1374
- const singular = PLURAL_TO_SINGULAR2[type] ?? type;
1375
- switch (singular) {
1376
- case "view": {
1377
- const t = item && typeof item === "object" && "type" in item ? String(item.type) : void 0;
1378
- return t && FORM_VIEW_TYPES.has(t) ? FormViewSchema : ListViewSchema;
1379
- }
1380
- case "dashboard":
1381
- return DashboardSchema;
1382
- default:
1383
- return null;
1384
- }
1560
+ function resolveOverlaySchema(type, _item) {
1561
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
1562
+ return getMetadataTypeSchema2(singular) ?? null;
1563
+ }
1564
+ function mergeArtifactProtection(item, artifactItem) {
1565
+ if (item === void 0 || item === null) return item;
1566
+ if (artifactItem === void 0 || artifactItem === null) return item;
1567
+ const a = artifactItem;
1568
+ if (typeof a !== "object") return item;
1569
+ const out = { ...item };
1570
+ if (a._lock !== void 0) out._lock = a._lock;
1571
+ if (a._lockReason !== void 0) out._lockReason = a._lockReason;
1572
+ if (a._packageId !== void 0) out._packageId = a._packageId;
1573
+ if (a._packageVersion !== void 0) out._packageVersion = a._packageVersion;
1574
+ if (a._provenance !== void 0) out._provenance = a._provenance;
1575
+ return out;
1385
1576
  }
1386
1577
  function simpleHash(str) {
1387
1578
  let hash = 0;
@@ -1514,6 +1705,32 @@ function extractPathValues(item, path) {
1514
1705
  }
1515
1706
  return out;
1516
1707
  }
1708
+ function diffShallow(from, to) {
1709
+ const added = [];
1710
+ const removed = [];
1711
+ const changed = [];
1712
+ const fromKeys = new Set(Object.keys(from ?? {}));
1713
+ const toKeys = new Set(Object.keys(to ?? {}));
1714
+ for (const k of toKeys) {
1715
+ if (!fromKeys.has(k)) {
1716
+ added.push({ path: k, value: to[k] });
1717
+ } else {
1718
+ const a = from[k];
1719
+ const b = to[k];
1720
+ const aStr = JSON.stringify(a);
1721
+ const bStr = JSON.stringify(b);
1722
+ if (aStr !== bStr) {
1723
+ changed.push({ path: k, from: a, to: b });
1724
+ }
1725
+ }
1726
+ }
1727
+ for (const k of fromKeys) {
1728
+ if (!toKeys.has(k)) {
1729
+ removed.push({ path: k, value: from[k] });
1730
+ }
1731
+ }
1732
+ return { added, removed, changed };
1733
+ }
1517
1734
  function detectDestructiveObjectChanges(prev, next) {
1518
1735
  if (!prev || typeof prev !== "object" || !next || typeof next !== "object") return [];
1519
1736
  const prevFields = prev.fields && typeof prev.fields === "object" ? prev.fields : {};
@@ -1645,6 +1862,20 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1645
1862
  }
1646
1863
  }
1647
1864
  }
1865
+ const draftPartialSql = "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft ON sys_metadata (type, name, organization_id) WHERE state = 'draft'";
1866
+ try {
1867
+ await exec(draftPartialSql);
1868
+ } catch (err) {
1869
+ const msg = err instanceof Error ? err.message : String(err);
1870
+ if (/partial|where clause|syntax/i.test(msg)) {
1871
+ try {
1872
+ await exec(
1873
+ "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft ON sys_metadata (type, name, organization_id)"
1874
+ );
1875
+ } catch {
1876
+ }
1877
+ }
1878
+ }
1648
1879
  } catch {
1649
1880
  }
1650
1881
  }
@@ -1768,8 +1999,8 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1768
1999
  DEFAULT_METADATA_TYPE_REGISTRY2.map((e) => [e.type, e])
1769
2000
  );
1770
2001
  const entries = allTypes.map((type) => {
1771
- const singular = PLURAL_TO_SINGULAR2[type] ?? type;
1772
- const zodSchema = singular === "view" ? ListViewSchema : TYPE_TO_SCHEMA[singular];
2002
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
2003
+ const zodSchema = getMetadataTypeSchema2(singular);
1773
2004
  const schema = (zodSchema ? toJsonSchemaSafe(zodSchema) : void 0) ?? HAND_CRAFTED_SCHEMAS[singular];
1774
2005
  const form = TYPE_TO_FORM[singular];
1775
2006
  const base = registryByType.get(singular);
@@ -1810,19 +2041,82 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1810
2041
  });
1811
2042
  return { types: allTypes, entries };
1812
2043
  }
2044
+ /**
2045
+ * Sweep all (or filtered) metadata types and report entries that
2046
+ * fail spec validation. Powers the Studio governance view
2047
+ * (`GET /api/v1/meta/diagnostics`) and `os doctor`-style CLI
2048
+ * checks.
2049
+ *
2050
+ * `severity` defaults to `'error'` — only entries with at least
2051
+ * one Zod error issue are returned. `'warning'` includes
2052
+ * everything we surface (warnings are reserved for a future lint
2053
+ * layer on top of spec validation).
2054
+ *
2055
+ * `type` may be either a singular (`'view'`) or plural (`'views'`)
2056
+ * identifier; the underlying `getMetaItems` already normalises.
2057
+ *
2058
+ * Implementation note: leverages the `_diagnostics` already
2059
+ * decorated onto items by `getMetaItems()` to avoid running
2060
+ * `safeParse()` twice. For types whose schema is unregistered we
2061
+ * skip silently (they cannot be validated and should not appear
2062
+ * as "valid" either — they are simply opaque to this report).
2063
+ */
2064
+ async getMetaDiagnostics(request = {}) {
2065
+ const includeWarnings = request.severity === "warning";
2066
+ const targetTypes = request.type ? [request.type] : DEFAULT_METADATA_TYPE_REGISTRY2.filter((e) => getMetadataTypeSchema2(e.type)).map((e) => e.type);
2067
+ const entries = [];
2068
+ const stats = {};
2069
+ let scannedItems = 0;
2070
+ for (const t of targetTypes) {
2071
+ let listed;
2072
+ try {
2073
+ listed = await this.getMetaItems({
2074
+ type: t,
2075
+ organizationId: request.organizationId,
2076
+ packageId: request.packageId
2077
+ });
2078
+ } catch {
2079
+ continue;
2080
+ }
2081
+ const items = Array.isArray(listed?.items) ? listed.items : Array.isArray(listed) ? listed : [];
2082
+ const pkgSet = /* @__PURE__ */ new Set();
2083
+ for (const item of items) {
2084
+ scannedItems += 1;
2085
+ const pkg = item?._packageId ?? null;
2086
+ if (pkg) pkgSet.add(pkg);
2087
+ const diag = item?._diagnostics ?? computeMetadataDiagnostics(t, item);
2088
+ if (!diag) continue;
2089
+ if (diag.valid && !includeWarnings) continue;
2090
+ if (diag.valid && includeWarnings && !diag.warnings?.length) continue;
2091
+ entries.push({
2092
+ type: t,
2093
+ name: typeof item?.name === "string" ? item.name : "<unknown>",
2094
+ diagnostics: diag
2095
+ });
2096
+ }
2097
+ stats[t] = { count: items.length, packages: [...pkgSet].sort() };
2098
+ }
2099
+ return {
2100
+ entries,
2101
+ total: entries.length,
2102
+ scannedTypes: targetTypes.length,
2103
+ scannedItems,
2104
+ stats
2105
+ };
2106
+ }
1813
2107
  async getMetaItems(request) {
1814
2108
  const { packageId } = request;
1815
2109
  let items = [];
1816
2110
  if (this.environmentId === void 0) {
1817
2111
  items = [...this.engine.registry.listItems(request.type, packageId)];
1818
2112
  if (items.length === 0) {
1819
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2113
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1820
2114
  if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
1821
2115
  }
1822
2116
  } else {
1823
2117
  items = [...this.engine.registry.listItems(request.type, packageId)];
1824
2118
  if (items.length === 0) {
1825
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2119
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1826
2120
  if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
1827
2121
  }
1828
2122
  }
@@ -1837,7 +2131,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1837
2131
  if (packageId) whereClause._packageId = packageId;
1838
2132
  let rs = await this.engine.find("sys_metadata", { where: whereClause });
1839
2133
  if (!rs || rs.length === 0) {
1840
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2134
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1841
2135
  if (alt) {
1842
2136
  const altWhere = { type: alt, state: "active", organization_id: oid };
1843
2137
  if (packageId) altWhere._packageId = packageId;
@@ -1904,28 +2198,38 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1904
2198
  }
1905
2199
  return {
1906
2200
  type: request.type,
1907
- items
2201
+ items: decorateMetadataItems(
2202
+ request.type,
2203
+ items.map((it) => {
2204
+ const a = this.lookupArtifactItem(
2205
+ request.type,
2206
+ it?.name
2207
+ );
2208
+ return mergeArtifactProtection(it, a);
2209
+ })
2210
+ )
1908
2211
  };
1909
2212
  }
1910
2213
  async getMetaItem(request) {
1911
2214
  let item;
1912
2215
  const orgId = request.organizationId;
2216
+ const readState = request.state === "draft" ? "draft" : "active";
1913
2217
  try {
1914
2218
  const findOverlay = async (oid) => {
1915
2219
  const where = {
1916
2220
  type: request.type,
1917
2221
  name: request.name,
1918
- state: "active",
2222
+ state: readState,
1919
2223
  organization_id: oid
1920
2224
  };
1921
2225
  const rec = await this.engine.findOne("sys_metadata", { where });
1922
2226
  if (rec) return rec;
1923
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2227
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1924
2228
  if (alt) {
1925
2229
  const altWhere = {
1926
2230
  type: alt,
1927
2231
  name: request.name,
1928
- state: "active",
2232
+ state: readState,
1929
2233
  organization_id: oid
1930
2234
  };
1931
2235
  return await this.engine.findOne("sys_metadata", { where: altWhere });
@@ -1938,6 +2242,17 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1938
2242
  }
1939
2243
  } catch {
1940
2244
  }
2245
+ if (readState === "draft") {
2246
+ if (item === void 0) {
2247
+ const err = new Error(
2248
+ `[no_draft] No pending draft exists for ${request.type}/${request.name}.`
2249
+ );
2250
+ err.code = "no_draft";
2251
+ err.status = 404;
2252
+ throw err;
2253
+ }
2254
+ return { type: request.type, name: request.name, item: decorateMetadataItem(request.type, item) };
2255
+ }
1941
2256
  if (item === void 0) {
1942
2257
  try {
1943
2258
  const services = this.getServicesRegistry?.();
@@ -1947,7 +2262,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1947
2262
  if (fromService !== void 0 && fromService !== null) {
1948
2263
  item = fromService;
1949
2264
  } else {
1950
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2265
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1951
2266
  if (alt) {
1952
2267
  const altFromService = await metadataService.get(alt, request.name);
1953
2268
  if (altFromService !== void 0 && altFromService !== null) {
@@ -1962,14 +2277,30 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1962
2277
  if (item === void 0) {
1963
2278
  item = this.engine.registry.getItem(request.type, request.name);
1964
2279
  if (item === void 0) {
1965
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2280
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1966
2281
  if (alt) item = this.engine.registry.getItem(alt, request.name);
1967
2282
  }
1968
2283
  }
2284
+ const artifactItem = this.lookupArtifactItem(request.type, request.name);
2285
+ const decorated = decorateMetadataItem(
2286
+ request.type,
2287
+ mergeArtifactProtection(item, artifactItem)
2288
+ );
2289
+ const artifactBacked = this.isArtifactBacked(request.type, request.name);
2290
+ const lockState = resolveLockState(decorated, artifactBacked);
1969
2291
  return {
1970
2292
  type: request.type,
1971
2293
  name: request.name,
1972
- item
2294
+ item: decorated,
2295
+ lock: lockState.lock,
2296
+ ...lockState.lockReason !== void 0 ? { lockReason: lockState.lockReason } : {},
2297
+ ...lockState.lockSource !== void 0 ? { lockSource: lockState.lockSource } : {},
2298
+ ...lockState.provenance !== void 0 ? { provenance: lockState.provenance } : {},
2299
+ ...lockState.packageId !== void 0 ? { packageId: lockState.packageId } : {},
2300
+ ...lockState.packageVersion !== void 0 ? { packageVersion: lockState.packageVersion } : {},
2301
+ editable: lockState.editable,
2302
+ deletable: lockState.deletable,
2303
+ resettable: lockState.resettable
1973
2304
  };
1974
2305
  }
1975
2306
  /**
@@ -1995,7 +2326,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1995
2326
  if (metadataService && typeof metadataService.get === "function") {
1996
2327
  let fromService = await metadataService.get(request.type, request.name);
1997
2328
  if (fromService === void 0 || fromService === null) {
1998
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2329
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1999
2330
  if (alt) fromService = await metadataService.get(alt, request.name);
2000
2331
  }
2001
2332
  if (fromService !== void 0 && fromService !== null) code = fromService;
@@ -2005,7 +2336,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2005
2336
  if (code === null) {
2006
2337
  let regItem = this.engine.registry.getItem(request.type, request.name);
2007
2338
  if (regItem === void 0) {
2008
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2339
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2009
2340
  if (alt) regItem = this.engine.registry.getItem(alt, request.name);
2010
2341
  }
2011
2342
  if (regItem !== void 0) code = regItem;
@@ -2022,7 +2353,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2022
2353
  };
2023
2354
  let rec = await this.engine.findOne("sys_metadata", { where });
2024
2355
  if (!rec) {
2025
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2356
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2026
2357
  if (alt) {
2027
2358
  rec = await this.engine.findOne("sys_metadata", {
2028
2359
  where: { ...where, type: alt }
@@ -2048,15 +2379,80 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2048
2379
  } catch {
2049
2380
  }
2050
2381
  const effective = overlay ?? code;
2382
+ const _diagnostics = effective !== null && effective !== void 0 ? computeMetadataDiagnostics(request.type, effective) : void 0;
2383
+ const artifactBacked = this.isArtifactBacked(request.type, request.name);
2384
+ const lockSource = code ?? overlay ?? {};
2385
+ const lockState = resolveLockState(lockSource, artifactBacked);
2051
2386
  return {
2052
2387
  type: request.type,
2053
2388
  name: request.name,
2054
2389
  code,
2055
2390
  overlay,
2056
2391
  overlayScope,
2057
- effective
2392
+ effective,
2393
+ ..._diagnostics ? { _diagnostics } : {},
2394
+ lock: lockState.lock,
2395
+ ...lockState.lockReason !== void 0 ? { lockReason: lockState.lockReason } : {},
2396
+ ...lockState.lockSource !== void 0 ? { lockSource: lockState.lockSource } : {},
2397
+ ...lockState.provenance !== void 0 ? { provenance: lockState.provenance } : {},
2398
+ ...lockState.packageId !== void 0 ? { packageId: lockState.packageId } : {},
2399
+ ...lockState.packageVersion !== void 0 ? { packageVersion: lockState.packageVersion } : {},
2400
+ editable: lockState.editable,
2401
+ deletable: lockState.deletable,
2402
+ resettable: lockState.resettable
2058
2403
  };
2059
2404
  }
2405
+ /**
2406
+ * ADR-0010 §3.6 / Phase 4.1 — read the metadata-protection audit log
2407
+ * for a single item. Returns the most-recent rows of
2408
+ * `sys_metadata_audit` for this (type, name) tuple, sorted newest
2409
+ * first. Refused (`denied`) and forced (`forced`) writes both appear
2410
+ * here — they never reach the `history` endpoint, which only tracks
2411
+ * successful body snapshots.
2412
+ *
2413
+ * The table is provisioned by `platform-objects` and is the
2414
+ * compliance surface for the lock-enforcement story. When the
2415
+ * environment has not yet provisioned the table (legacy install
2416
+ * prior to ADR-0010) the call returns `{ events: [] }` instead of
2417
+ * raising, keeping the Studio tab harmless.
2418
+ */
2419
+ async auditMetaItem(request) {
2420
+ const singular = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
2421
+ const limit = Math.min(
2422
+ Math.max(1, request.limit ?? 100),
2423
+ 500
2424
+ );
2425
+ try {
2426
+ const where = {
2427
+ type: singular,
2428
+ name: request.name
2429
+ };
2430
+ const rows = await this.engine.find("sys_metadata_audit", {
2431
+ where,
2432
+ orderBy: [{ field: "occurred_at", direction: "desc" }],
2433
+ limit
2434
+ });
2435
+ const events = (Array.isArray(rows) ? rows : []).map((r) => ({
2436
+ id: r.id,
2437
+ occurredAt: typeof r.occurred_at === "string" ? r.occurred_at : r.occurred_at instanceof Date ? r.occurred_at.toISOString() : String(r.occurred_at ?? ""),
2438
+ actor: String(r.actor ?? "system"),
2439
+ source: r.source ?? null,
2440
+ operation: r.operation,
2441
+ outcome: r.outcome,
2442
+ code: String(r.code ?? ""),
2443
+ lockState: r.lock_state ?? null,
2444
+ lockOverridden: Boolean(r.lock_overridden),
2445
+ requestId: r.request_id ?? null,
2446
+ note: r.note ?? null
2447
+ }));
2448
+ return { events };
2449
+ } catch (err) {
2450
+ console.warn(
2451
+ `[Protocol] auditMetaItem read failed for ${request.type}/${request.name}: ${err?.message ?? err}`
2452
+ );
2453
+ return { events: [] };
2454
+ }
2455
+ }
2060
2456
  async getUiView(request) {
2061
2457
  const schema = this.engine.registry.getObject(request.object);
2062
2458
  if (!schema) throw new Error(`Object ${request.object} not found`);
@@ -2935,7 +3331,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2935
3331
  for (const tok of raw.split(",")) {
2936
3332
  const t = tok.trim();
2937
3333
  if (!t) continue;
2938
- const singular = PLURAL_TO_SINGULAR2[t] ?? t;
3334
+ const singular = PLURAL_TO_SINGULAR3[t] ?? t;
2939
3335
  set.add(singular);
2940
3336
  const plural = SINGULAR_TO_PLURAL2[singular];
2941
3337
  if (plural) set.add(plural);
@@ -2949,13 +3345,201 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2949
3345
  }
2950
3346
  /** Normalize plural→singular before consulting the allow-list. */
2951
3347
  static isOverlayAllowed(type) {
2952
- const singular = PLURAL_TO_SINGULAR2[type] ?? type;
3348
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
2953
3349
  if (this.OVERLAY_ALLOWED_TYPES.has(singular) || this.OVERLAY_ALLOWED_TYPES.has(type)) {
2954
3350
  return true;
2955
3351
  }
2956
3352
  const env = this.envWritableTypes();
2957
3353
  return env.has(singular) || env.has(type);
2958
3354
  }
3355
+ /** Does this type permit creating brand-new (artifact-free) items? */
3356
+ static isRuntimeCreateAllowed(type) {
3357
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3358
+ if (this.RUNTIME_CREATE_ALLOWED_TYPES.has(singular) || this.RUNTIME_CREATE_ALLOWED_TYPES.has(type)) {
3359
+ return true;
3360
+ }
3361
+ if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) {
3362
+ return true;
3363
+ }
3364
+ return false;
3365
+ }
3366
+ /**
3367
+ * Does an artifact (npm-package-loaded) item exist at `(type, name)`?
3368
+ *
3369
+ * The schema registry's `_packageId` tag is set only when
3370
+ * `registerItem(..., packageId)` is called with a truthy packageId
3371
+ * — and only artifact loaders do that. DB-rehydrated items
3372
+ * (sys_metadata rows registered back into the registry by
3373
+ * `getMetaItems` / `loadMetaFromDb`) call `registerItem` without a
3374
+ * packageId, so they carry no `_packageId` and are correctly
3375
+ * excluded here.
3376
+ *
3377
+ * Used by the two-tier authorization model to distinguish
3378
+ * "overlaying a packaged item" (requires `allowOrgOverride`) from
3379
+ * "authoring a DB-only item" (requires only `allowRuntimeCreate`).
3380
+ */
3381
+ isArtifactBacked(type, name) {
3382
+ const registry = this.engine?.registry;
3383
+ if (!registry || typeof registry.getItem !== "function") {
3384
+ return false;
3385
+ }
3386
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3387
+ const item = registry.getItem(singular, name) ?? registry.getItem(type, name);
3388
+ if (!item || !item._packageId) return false;
3389
+ return item._packageId !== "sys_metadata";
3390
+ }
3391
+ // ───────────────────────────────────────────────────────────────────
3392
+ // ADR-0010 — metadata protection (Phase 1: L3 item-level lock)
3393
+ // ───────────────────────────────────────────────────────────────────
3394
+ /**
3395
+ * Look up an item from the artifact registry across both the requested
3396
+ * type and its singular/plural twin. Returns `undefined` when the
3397
+ * registry is unavailable or the item is not artifact-backed.
3398
+ */
3399
+ lookupArtifactItem(type, name) {
3400
+ const registry = this.engine?.registry;
3401
+ if (!registry || typeof registry.getItem !== "function") return void 0;
3402
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3403
+ return registry.getItem(singular, name) ?? registry.getItem(type, name);
3404
+ }
3405
+ /**
3406
+ * Resolve the effective `_lock` for an item by consulting the
3407
+ * artifact registry first, then the persisted overlay row. Artifact
3408
+ * always wins — by design, an overlay cannot loosen a packaged
3409
+ * lock (ADR-0010 §3.3).
3410
+ *
3411
+ * Returns `'none'` when nothing is locked, which is the common
3412
+ * case. Safe to call when `environmentId` is undefined (control-
3413
+ * plane bootstrap) — the lock check is only meaningful in tenant
3414
+ * scope and the caller is expected to also gate on `environmentId`.
3415
+ */
3416
+ async getEffectiveLock(type, name, organizationId) {
3417
+ const registry = this.engine?.registry;
3418
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3419
+ let artifactItem;
3420
+ if (registry && typeof registry.getItem === "function") {
3421
+ artifactItem = registry.getItem(singular, name) ?? registry.getItem(type, name);
3422
+ }
3423
+ if (artifactItem && artifactItem._packageId && artifactItem._packageId !== "sys_metadata") {
3424
+ const p = extractProtection(artifactItem);
3425
+ if (p.lock !== "none") {
3426
+ return { lock: p.lock, lockReason: p.lockReason, lockSource: "artifact" };
3427
+ }
3428
+ }
3429
+ try {
3430
+ const where = {
3431
+ type,
3432
+ name,
3433
+ state: "active",
3434
+ organization_id: organizationId ?? null
3435
+ };
3436
+ const row = await this.engine.findOne("sys_metadata", { where });
3437
+ if (row) {
3438
+ const body = typeof row.metadata === "string" ? JSON.parse(row.metadata) : row.metadata;
3439
+ const p = extractProtection(body);
3440
+ if (p.lock !== "none") {
3441
+ return { lock: p.lock, lockReason: p.lockReason, lockSource: "overlay" };
3442
+ }
3443
+ }
3444
+ } catch {
3445
+ }
3446
+ return { lock: "none", lockReason: void 0, lockSource: void 0 };
3447
+ }
3448
+ /**
3449
+ * Best-effort audit-row writer (ADR-0010 §3.6). Failures here are
3450
+ * logged but never block the underlying decision: an environment
3451
+ * without the audit table provisioned (legacy installs before this
3452
+ * ADR landed) still answers normal API calls, just without the
3453
+ * compliance trail. Phase 2 will make the audit table a hard
3454
+ * dependency.
3455
+ */
3456
+ async recordMetadataAudit(entry) {
3457
+ try {
3458
+ await this.engine.insert("sys_metadata_audit", {
3459
+ occurred_at: (/* @__PURE__ */ new Date()).toISOString(),
3460
+ actor: entry.actor ?? "system",
3461
+ source: entry.source ?? "protocol",
3462
+ type: PLURAL_TO_SINGULAR3[entry.type] ?? entry.type,
3463
+ name: entry.name,
3464
+ organization_id: entry.organizationId ?? null,
3465
+ operation: entry.operation,
3466
+ outcome: entry.outcome,
3467
+ code: entry.code,
3468
+ lock_state: entry.lockState ?? "none",
3469
+ lock_overridden: entry.lockOverridden ?? false,
3470
+ request_id: entry.requestId ?? null,
3471
+ note: entry.note ?? null
3472
+ });
3473
+ } catch (err) {
3474
+ console.warn(
3475
+ `[Protocol] sys_metadata_audit write failed for ${entry.type}/${entry.name}: ${err?.message ?? err}`
3476
+ );
3477
+ }
3478
+ }
3479
+ /**
3480
+ * Phase 1 L3 enforcement for write operations (save / publish /
3481
+ * rollback). Returns null on allow. Returns the structured `Error`
3482
+ * the caller should `throw` on deny — also records the denial in
3483
+ * the audit log so refused attempts are visible in compliance
3484
+ * reports (refused writes never reach sys_metadata_history).
3485
+ */
3486
+ async assertLockAllowsWrite(args) {
3487
+ if (this.environmentId === void 0) return null;
3488
+ const state = await this.getEffectiveLock(args.type, args.name, args.organizationId ?? null);
3489
+ const refusal = evaluateLockForWrite(state.lock);
3490
+ if (!refusal) return null;
3491
+ const reason = state.lockReason ?? refusal.reason;
3492
+ const err = new Error(
3493
+ `[item_locked] ${args.type}/${args.name} is locked (_lock=${state.lock}${state.lockSource ? `, source=${state.lockSource}` : ""}). ${reason} \u2014 See ADR-0010 \xA73.3.`
3494
+ );
3495
+ err.code = "item_locked";
3496
+ err.status = 403;
3497
+ err.lock = state.lock;
3498
+ err.lockReason = reason;
3499
+ await this.recordMetadataAudit({
3500
+ type: args.type,
3501
+ name: args.name,
3502
+ organizationId: args.organizationId ?? null,
3503
+ operation: args.operation,
3504
+ outcome: "denied",
3505
+ code: "item_locked",
3506
+ lockState: state.lock,
3507
+ actor: args.actor,
3508
+ source: args.source ?? `protocol.${args.operation}MetaItem`,
3509
+ requestId: args.requestId,
3510
+ note: reason
3511
+ });
3512
+ return err;
3513
+ }
3514
+ /** Counterpart of {@link assertLockAllowsWrite} for delete. */
3515
+ async assertLockAllowsDelete(args) {
3516
+ if (this.environmentId === void 0) return null;
3517
+ const state = await this.getEffectiveLock(args.type, args.name, args.organizationId ?? null);
3518
+ const refusal = evaluateLockForDelete(state.lock);
3519
+ if (!refusal) return null;
3520
+ const reason = state.lockReason ?? refusal.reason;
3521
+ const err = new Error(
3522
+ `[item_locked] ${args.type}/${args.name} is locked (_lock=${state.lock}${state.lockSource ? `, source=${state.lockSource}` : ""}). ${reason} \u2014 See ADR-0010 \xA73.3.`
3523
+ );
3524
+ err.code = "item_locked";
3525
+ err.status = 403;
3526
+ err.lock = state.lock;
3527
+ err.lockReason = reason;
3528
+ await this.recordMetadataAudit({
3529
+ type: args.type,
3530
+ name: args.name,
3531
+ organizationId: args.organizationId ?? null,
3532
+ operation: "delete",
3533
+ outcome: "denied",
3534
+ code: "item_locked",
3535
+ lockState: state.lock,
3536
+ actor: args.actor,
3537
+ source: args.source ?? "protocol.deleteMetaItem",
3538
+ requestId: args.requestId,
3539
+ note: reason
3540
+ });
3541
+ return err;
3542
+ }
2959
3543
  /**
2960
3544
  * Mirror an object-type overlay write into the in-memory engine
2961
3545
  * registry so subsequent CRUD finds the new schema. Idempotent and
@@ -2981,16 +3565,38 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2981
3565
  if (!request.item) {
2982
3566
  throw new Error("Item data is required");
2983
3567
  }
2984
- if (this.environmentId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
2985
- const allowed = Array.from(_ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES).join(", ");
2986
- const err = new Error(
2987
- `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. Set allowOrgOverride: true on its DEFAULT_METADATA_TYPE_REGISTRY entry to enable, or set the OBJECTSTACK_METADATA_WRITABLE env var (comma-separated singular type names) to opt in at runtime. Currently allowed: ${allowed}. See docs/adr/0005-metadata-customization-overlay.md.`
2988
- );
2989
- err.code = "not_overridable";
2990
- err.status = 403;
2991
- throw err;
3568
+ const mode = request.mode === "draft" ? "draft" : "publish";
3569
+ if (this.environmentId !== void 0) {
3570
+ const overlayAllowed = _ObjectStackProtocolImplementation.isOverlayAllowed(request.type);
3571
+ const runtimeCreateAllowed = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(request.type);
3572
+ const artifactBacked = this.isArtifactBacked(request.type, request.name);
3573
+ if (artifactBacked && !overlayAllowed) {
3574
+ const err = new Error(
3575
+ `[not_overridable] Metadata item '${request.type}/${request.name}' is provided by a code package and the type has not opted into per-org overlay writes (allowOrgOverride=false). Edit the source artifact and redeploy, or set OBJECTSTACK_METADATA_WRITABLE to grant a runtime escape hatch. See docs/adr/0005-metadata-customization-overlay.md.`
3576
+ );
3577
+ err.code = "not_overridable";
3578
+ err.status = 403;
3579
+ throw err;
3580
+ }
3581
+ if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) {
3582
+ const err = new Error(
3583
+ `[not_creatable] Metadata type '${request.type}' does not allow runtime creation (allowRuntimeCreate=false, allowOrgOverride=false). New items of this type must be defined in source code.`
3584
+ );
3585
+ err.code = "not_creatable";
3586
+ err.status = 403;
3587
+ throw err;
3588
+ }
3589
+ const lockErr = await this.assertLockAllowsWrite({
3590
+ type: request.type,
3591
+ name: request.name,
3592
+ ...request.organizationId ? { organizationId: request.organizationId } : {},
3593
+ operation: "save",
3594
+ ...request.actor ? { actor: request.actor } : {},
3595
+ source: "protocol.saveMetaItem"
3596
+ });
3597
+ if (lockErr) throw lockErr;
2992
3598
  }
2993
- const singularType = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3599
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
2994
3600
  if (!request.force && (singularType === "object" || singularType === "field")) {
2995
3601
  try {
2996
3602
  const existing = await this.getMetaItem({
@@ -3038,8 +3644,13 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3038
3644
  }
3039
3645
  }
3040
3646
  await this.ensureOverlayIndex();
3041
- const singularTypeForRepo = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3042
- if (_ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo)) {
3647
+ const singularTypeForRepo = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3648
+ const overlayAllowedForRepo = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
3649
+ const runtimeCreateAllowedForRepo = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularTypeForRepo);
3650
+ const useRepoPath = overlayAllowedForRepo || runtimeCreateAllowedForRepo;
3651
+ if (useRepoPath) {
3652
+ const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name);
3653
+ const intent = artifactBacked ? "override-artifact" : "runtime-only";
3043
3654
  const orgId = request.organizationId ?? null;
3044
3655
  const repo = this.getOverlayRepo(orgId);
3045
3656
  const ref = {
@@ -3051,21 +3662,37 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3051
3662
  if (request.parentVersion !== void 0) {
3052
3663
  parentVersion = request.parentVersion;
3053
3664
  } else {
3054
- const current = await repo.get(ref);
3665
+ const current = await repo.get(ref, { state: mode === "draft" ? "draft" : "active" });
3055
3666
  parentVersion = current?.hash ?? null;
3056
3667
  }
3057
3668
  try {
3058
3669
  const result = await repo.put(ref, request.item, {
3059
3670
  parentVersion,
3060
3671
  actor: request.actor ?? "system",
3061
- source: "protocol.saveMetaItem"
3672
+ source: "protocol.saveMetaItem",
3673
+ intent,
3674
+ state: mode === "draft" ? "draft" : "active"
3675
+ });
3676
+ if (mode === "publish") {
3677
+ this.applyObjectRegistryMutation(request);
3678
+ }
3679
+ await this.recordMetadataAudit({
3680
+ type: request.type,
3681
+ name: request.name,
3682
+ organizationId: orgId,
3683
+ operation: "save",
3684
+ outcome: "allowed",
3685
+ code: "ok",
3686
+ ...request.actor ? { actor: request.actor } : {},
3687
+ source: "protocol.saveMetaItem",
3688
+ note: mode === "draft" ? "draft" : "active"
3062
3689
  });
3063
- this.applyObjectRegistryMutation(request);
3064
3690
  return {
3065
3691
  success: true,
3066
3692
  version: result.version,
3067
3693
  seq: result.seq,
3068
- message: orgId ? `Saved customization overlay (org=${orgId}) \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]` : `Saved customization overlay (env-wide) \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
3694
+ state: mode === "draft" ? "draft" : "active",
3695
+ message: orgId ? `Saved customization overlay (org=${orgId}, state=${mode === "draft" ? "draft" : "active"}) \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]` : `Saved customization overlay (env-wide, state=${mode === "draft" ? "draft" : "active"}) \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
3069
3696
  };
3070
3697
  } catch (err) {
3071
3698
  if (err instanceof ConflictError2) {
@@ -3149,8 +3776,8 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3149
3776
  * "no history" uniformly.
3150
3777
  */
3151
3778
  async historyMetaItem(request) {
3152
- const singularType = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3153
- if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType)) {
3779
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3780
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !_ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) {
3154
3781
  return { events: [] };
3155
3782
  }
3156
3783
  const orgId = request.organizationId ?? null;
@@ -3168,22 +3795,264 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3168
3795
  return { events };
3169
3796
  }
3170
3797
  /**
3171
- * Remove a customization overlay row for the given metadata item, so the
3172
- * next read falls through to the artifact-loaded default. Implements the
3173
- * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
3174
- * with {@link saveMetaItem}.
3798
+ * Promote the pending draft overlay to the live (`active`) row.
3799
+ * Records a history event with `op='publish'`. 404 (`[no_draft]`)
3800
+ * when there is nothing to publish.
3175
3801
  */
3176
- async deleteMetaItem(request) {
3177
- if (this.environmentId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
3802
+ async publishMetaItem(request) {
3803
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3804
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !_ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) {
3805
+ const err = new Error(
3806
+ `[not_overridable] Metadata type '${request.type}' is not draftable \u2014 no overlay/runtime-create permission.`
3807
+ );
3808
+ err.code = "not_overridable";
3809
+ err.status = 403;
3810
+ throw err;
3811
+ }
3812
+ const _publishLockErr = await this.assertLockAllowsWrite({
3813
+ type: request.type,
3814
+ name: request.name,
3815
+ ...request.organizationId ? { organizationId: request.organizationId } : {},
3816
+ operation: "publish",
3817
+ ...request.actor ? { actor: request.actor } : {},
3818
+ source: "protocol.publishMetaItem"
3819
+ });
3820
+ if (_publishLockErr) throw _publishLockErr;
3821
+ await this.ensureOverlayIndex();
3822
+ const orgId = request.organizationId ?? null;
3823
+ const repo = this.getOverlayRepo(orgId);
3824
+ const artifactBacked = this.isArtifactBacked(singularType, request.name);
3825
+ const intent = artifactBacked ? "override-artifact" : "runtime-only";
3826
+ const ref = {
3827
+ type: singularType,
3828
+ name: request.name,
3829
+ org: orgId ?? "env"
3830
+ };
3831
+ try {
3832
+ const result = await repo.promoteDraft(ref, {
3833
+ actor: request.actor ?? "system",
3834
+ source: "protocol.publishMetaItem",
3835
+ ...request.message ? { message: request.message } : {},
3836
+ intent
3837
+ });
3838
+ this.applyObjectRegistryMutation({
3839
+ type: request.type,
3840
+ name: request.name,
3841
+ item: result.item.body
3842
+ });
3843
+ return {
3844
+ success: true,
3845
+ version: result.version,
3846
+ seq: result.seq,
3847
+ message: `Published draft \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
3848
+ };
3849
+ } catch (err) {
3850
+ if (err instanceof ConflictError2) {
3851
+ const conflict = new Error(
3852
+ `[metadata_conflict] ${request.type}/${request.name} published row advanced while you held the draft. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
3853
+ );
3854
+ conflict.code = "metadata_conflict";
3855
+ conflict.status = 409;
3856
+ conflict.expectedParent = err.expectedParent;
3857
+ conflict.actualHead = err.actualHead;
3858
+ throw conflict;
3859
+ }
3860
+ throw err;
3861
+ }
3862
+ }
3863
+ /**
3864
+ * Restore the body recorded at history `toVersion` as the new
3865
+ * live row. Writes a history event with `op='revert'`. 404
3866
+ * (`[version_not_found]`) when the target version doesn't exist;
3867
+ * 409 (`[version_not_restorable]`) when the target is a delete
3868
+ * tombstone (no body to bring back).
3869
+ */
3870
+ async rollbackMetaItem(request) {
3871
+ if (!Number.isFinite(request.toVersion) || request.toVersion < 1) {
3178
3872
  const err = new Error(
3179
- `[not_overridable] Metadata type '${request.type}' has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
3873
+ `[invalid_request] rollbackMetaItem requires a positive integer 'toVersion' (got ${request.toVersion}).`
3874
+ );
3875
+ err.code = "invalid_request";
3876
+ err.status = 400;
3877
+ throw err;
3878
+ }
3879
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3880
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !_ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) {
3881
+ const err = new Error(
3882
+ `[not_overridable] Metadata type '${request.type}' is not revertable \u2014 no overlay/runtime-create permission.`
3180
3883
  );
3181
3884
  err.code = "not_overridable";
3182
3885
  err.status = 403;
3183
3886
  throw err;
3184
3887
  }
3185
- const singularTypeForRepo = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3186
- const useRepoPath = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
3888
+ const _rollbackLockErr = await this.assertLockAllowsWrite({
3889
+ type: request.type,
3890
+ name: request.name,
3891
+ ...request.organizationId ? { organizationId: request.organizationId } : {},
3892
+ operation: "rollback",
3893
+ ...request.actor ? { actor: request.actor } : {},
3894
+ source: "protocol.rollbackMetaItem"
3895
+ });
3896
+ if (_rollbackLockErr) throw _rollbackLockErr;
3897
+ await this.ensureOverlayIndex();
3898
+ const orgId = request.organizationId ?? null;
3899
+ const repo = this.getOverlayRepo(orgId);
3900
+ const artifactBacked = this.isArtifactBacked(singularType, request.name);
3901
+ const intent = artifactBacked ? "override-artifact" : "runtime-only";
3902
+ const ref = {
3903
+ type: singularType,
3904
+ name: request.name,
3905
+ org: orgId ?? "env"
3906
+ };
3907
+ try {
3908
+ const result = await repo.restoreVersion(ref, request.toVersion, {
3909
+ actor: request.actor ?? "system",
3910
+ source: "protocol.rollbackMetaItem",
3911
+ ...request.message ? { message: request.message } : {},
3912
+ intent
3913
+ });
3914
+ this.applyObjectRegistryMutation({
3915
+ type: request.type,
3916
+ name: request.name,
3917
+ item: result.item.body
3918
+ });
3919
+ return {
3920
+ success: true,
3921
+ version: result.version,
3922
+ seq: result.seq,
3923
+ restoredFromVersion: request.toVersion,
3924
+ message: `Reverted to version ${request.toVersion} \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
3925
+ };
3926
+ } catch (err) {
3927
+ if (err instanceof ConflictError2) {
3928
+ const conflict = new Error(
3929
+ `[metadata_conflict] ${request.type}/${request.name} advanced during rollback. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
3930
+ );
3931
+ conflict.code = "metadata_conflict";
3932
+ conflict.status = 409;
3933
+ conflict.expectedParent = err.expectedParent;
3934
+ conflict.actualHead = err.actualHead;
3935
+ throw conflict;
3936
+ }
3937
+ throw err;
3938
+ }
3939
+ }
3940
+ /**
3941
+ * Compute a shallow structural diff between two historical
3942
+ * versions of a metadata item. Either side may be omitted: when
3943
+ * `toVersion` is undefined the current active body is used; when
3944
+ * `fromVersion` is undefined the immediately previous history row
3945
+ * is used. Returns `{ added, removed, changed }` keyed by JSON
3946
+ * pointer-style paths for primitive leaves; nested objects/arrays
3947
+ * are reported as a single change record.
3948
+ */
3949
+ async diffMetaItem(request) {
3950
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3951
+ const orgId = request.organizationId ?? null;
3952
+ const events = (await this.historyMetaItem({
3953
+ type: singularType,
3954
+ name: request.name,
3955
+ ...orgId ? { organizationId: orgId } : {}
3956
+ })).events;
3957
+ const versions = events.map((ev) => ev.version).filter((v) => typeof v === "number");
3958
+ const repo = this.getOverlayRepo(orgId);
3959
+ const fullRef = {
3960
+ type: singularType,
3961
+ name: request.name,
3962
+ org: orgId ?? "env"
3963
+ };
3964
+ const histRows = [];
3965
+ try {
3966
+ const engineAny = this.engine;
3967
+ const rows = await engineAny.find("sys_metadata_history", {
3968
+ where: {
3969
+ organization_id: orgId,
3970
+ type: singularType,
3971
+ name: request.name
3972
+ }
3973
+ });
3974
+ rows.sort((a, b) => (a.version ?? 0) - (b.version ?? 0));
3975
+ for (const r of rows) {
3976
+ const body = r.metadata == null ? null : typeof r.metadata === "string" ? JSON.parse(r.metadata) : r.metadata;
3977
+ histRows.push({ version: r.version ?? 0, body });
3978
+ }
3979
+ } catch {
3980
+ }
3981
+ const byVersion = /* @__PURE__ */ new Map();
3982
+ for (const r of histRows) byVersion.set(r.version, r.body);
3983
+ let fromBody = null;
3984
+ let toBody = null;
3985
+ let fromVersion = null;
3986
+ let toVersion = null;
3987
+ if (request.toVersion !== void 0) {
3988
+ toVersion = request.toVersion;
3989
+ toBody = byVersion.get(request.toVersion) ?? null;
3990
+ } else {
3991
+ const current = await repo.get(fullRef, { state: "active" });
3992
+ toBody = current ? current.body : null;
3993
+ toVersion = histRows.length ? histRows[histRows.length - 1].version : null;
3994
+ }
3995
+ if (request.fromVersion !== void 0) {
3996
+ fromVersion = request.fromVersion;
3997
+ fromBody = byVersion.get(request.fromVersion) ?? null;
3998
+ } else if (toVersion !== null) {
3999
+ const sorted = histRows.map((r) => r.version).filter((v) => v < toVersion);
4000
+ if (sorted.length) {
4001
+ fromVersion = sorted[sorted.length - 1];
4002
+ fromBody = byVersion.get(fromVersion) ?? null;
4003
+ }
4004
+ }
4005
+ const diff = diffShallow(fromBody ?? {}, toBody ?? {});
4006
+ const _used = versions;
4007
+ void _used;
4008
+ return {
4009
+ type: request.type,
4010
+ name: request.name,
4011
+ fromVersion,
4012
+ toVersion,
4013
+ ...diff
4014
+ };
4015
+ }
4016
+ /**
4017
+ * Remove a customization overlay row for the given metadata item, so the
4018
+ * next read falls through to the artifact-loaded default. Implements the
4019
+ * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
4020
+ * with {@link saveMetaItem}.
4021
+ */
4022
+ async deleteMetaItem(request) {
4023
+ if (this.environmentId !== void 0) {
4024
+ const overlayAllowed = _ObjectStackProtocolImplementation.isOverlayAllowed(request.type);
4025
+ const runtimeCreateAllowed = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(request.type);
4026
+ const artifactBacked = this.isArtifactBacked(request.type, request.name);
4027
+ if (artifactBacked && !overlayAllowed) {
4028
+ const err = new Error(
4029
+ `[not_overridable] Metadata item '${request.type}/${request.name}' is provided by a code package and the type has not opted into per-org overlay writes. See docs/adr/0005-metadata-customization-overlay.md.`
4030
+ );
4031
+ err.code = "not_overridable";
4032
+ err.status = 403;
4033
+ throw err;
4034
+ }
4035
+ if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) {
4036
+ const err = new Error(
4037
+ `[not_creatable] Metadata type '${request.type}' does not allow runtime creation or deletion.`
4038
+ );
4039
+ err.code = "not_creatable";
4040
+ err.status = 403;
4041
+ throw err;
4042
+ }
4043
+ const lockErr = await this.assertLockAllowsDelete({
4044
+ type: request.type,
4045
+ name: request.name,
4046
+ ...request.organizationId ? { organizationId: request.organizationId } : {},
4047
+ ...request.actor ? { actor: request.actor } : {},
4048
+ source: "protocol.deleteMetaItem"
4049
+ });
4050
+ if (lockErr) throw lockErr;
4051
+ }
4052
+ const singularTypeForRepo = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
4053
+ const overlayAllowedForRepoDel = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
4054
+ const runtimeCreateAllowedForRepoDel = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularTypeForRepo);
4055
+ const useRepoPath = overlayAllowedForRepoDel || runtimeCreateAllowedForRepoDel;
3187
4056
  if (useRepoPath) {
3188
4057
  const orgId = request.organizationId ?? null;
3189
4058
  const repo = this.getOverlayRepo(orgId);
@@ -3193,19 +4062,22 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3193
4062
  org: orgId ?? "env"
3194
4063
  };
3195
4064
  try {
3196
- const current = await repo.get(ref);
4065
+ const targetState = request.state === "draft" ? "draft" : "active";
4066
+ const current = await repo.get(ref, { state: targetState });
3197
4067
  if (!current) {
3198
4068
  return {
3199
4069
  success: true,
3200
4070
  reset: false,
3201
- message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
4071
+ message: targetState === "draft" ? `No pending draft for ${request.type}/${request.name}.` : `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
3202
4072
  };
3203
4073
  }
3204
4074
  const parentVersion = request.parentVersion !== void 0 ? request.parentVersion ?? current.hash : current.hash;
3205
4075
  const result = await repo.delete(ref, {
3206
4076
  parentVersion,
3207
4077
  actor: request.actor ?? "system",
3208
- source: "protocol.deleteMetaItem"
4078
+ source: "protocol.deleteMetaItem",
4079
+ intent: this.isArtifactBacked(singularTypeForRepo, request.name) ? "override-artifact" : "runtime-only",
4080
+ state: targetState
3209
4081
  });
3210
4082
  if (this.environmentId === void 0) {
3211
4083
  try {
@@ -3220,11 +4092,22 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3220
4092
  } catch {
3221
4093
  }
3222
4094
  }
4095
+ await this.recordMetadataAudit({
4096
+ type: request.type,
4097
+ name: request.name,
4098
+ organizationId: orgId,
4099
+ operation: "delete",
4100
+ outcome: "allowed",
4101
+ code: "ok",
4102
+ ...request.actor ? { actor: request.actor } : {},
4103
+ source: "protocol.deleteMetaItem",
4104
+ note: targetState
4105
+ });
3223
4106
  return {
3224
4107
  success: true,
3225
4108
  reset: true,
3226
4109
  seq: result.seq,
3227
- message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`
4110
+ message: request.state === "draft" ? `Draft discarded \u2014 ${request.type}/${request.name}. [seq=${result.seq}]` : `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`
3228
4111
  };
3229
4112
  } catch (err) {
3230
4113
  if (err instanceof ConflictError2) {
@@ -3302,7 +4185,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3302
4185
  for (const record of records) {
3303
4186
  try {
3304
4187
  const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
3305
- const normalizedType = PLURAL_TO_SINGULAR2[record.type] ?? record.type;
4188
+ const normalizedType = PLURAL_TO_SINGULAR3[record.type] ?? record.type;
3306
4189
  if (normalizedType === "object") {
3307
4190
  this.engine.registry.registerObject(data, record.packageId || "sys_metadata");
3308
4191
  } else {
@@ -3336,7 +4219,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3336
4219
  * — the engine never throws.
3337
4220
  */
3338
4221
  async findReferencesToMeta(request) {
3339
- const singularTarget = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
4222
+ const singularTarget = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3340
4223
  const targetName = request.name;
3341
4224
  const matchers = REFERENCE_PATHS[singularTarget];
3342
4225
  if (!matchers || matchers.length === 0) {
@@ -3547,6 +4430,41 @@ _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
3547
4430
  * {@link ObjectStackProtocolImplementation.resetEnvWritableCache}.
3548
4431
  */
3549
4432
  _ObjectStackProtocolImplementation._envWritableTypes = null;
4433
+ /**
4434
+ * Types that opt into runtime creation of brand-new items (ADR-0005
4435
+ * extension — two-tier model). A type may have
4436
+ * `allowOrgOverride: false` (cannot overlay artifact-shipped items)
4437
+ * yet still set `allowRuntimeCreate: true` (users can author new
4438
+ * items in `sys_metadata`). The two flags are orthogonal; see
4439
+ * {@link isArtifactBacked} for how the protocol decides which gate
4440
+ * applies to a given save/delete.
4441
+ */
4442
+ /**
4443
+ * Set of type names that have a static entry in
4444
+ * `DEFAULT_METADATA_TYPE_REGISTRY`. Anything outside this set is
4445
+ * runtime-registered (plugin-provided types like `theme`, `api`,
4446
+ * `connector`) — the listing endpoint at `getMetaTypes()` synthesises
4447
+ * those with `allowRuntimeCreate: true`, so this gate must agree.
4448
+ */
4449
+ _ObjectStackProtocolImplementation.STATIC_REGISTRY_TYPES = (() => {
4450
+ const out = /* @__PURE__ */ new Set();
4451
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY2) {
4452
+ out.add(entry.type);
4453
+ const plural = SINGULAR_TO_PLURAL2[entry.type];
4454
+ if (plural) out.add(plural);
4455
+ }
4456
+ return out;
4457
+ })();
4458
+ _ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES = (() => {
4459
+ const out = /* @__PURE__ */ new Set();
4460
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY2) {
4461
+ if (!entry.allowRuntimeCreate) continue;
4462
+ out.add(entry.type);
4463
+ const plural = SINGULAR_TO_PLURAL2[entry.type];
4464
+ if (plural) out.add(plural);
4465
+ }
4466
+ return out;
4467
+ })();
3550
4468
  var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
3551
4469
 
3552
4470
  // src/engine.ts