@objectstack/objectql 7.0.0 → 7.1.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
  }
@@ -626,6 +634,12 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from "@objectstack/spec/shared
626
634
  var OVERLAY_ALLOWED_TYPES = new Set(
627
635
  DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowOrgOverride).map((e) => e.type)
628
636
  );
637
+ var STATIC_REGISTRY_TYPES = new Set(
638
+ DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type)
639
+ );
640
+ var RUNTIME_CREATE_ALLOWED_TYPES = new Set(
641
+ DEFAULT_METADATA_TYPE_REGISTRY.filter((e) => e.allowRuntimeCreate).map((e) => e.type)
642
+ );
629
643
  var _envWritableMetadataTypes = null;
630
644
  function envWritableMetadataTypes() {
631
645
  if (_envWritableMetadataTypes !== null) return _envWritableMetadataTypes;
@@ -675,11 +689,16 @@ var SysMetadataRepository = class {
675
689
  /**
676
690
  * Read the current overlay row. Returns null if no row exists —
677
691
  * callers (e.g. LayeredRepository) fall through to lower layers.
692
+ *
693
+ * `opts.state` selects which lifecycle row to read: defaults to the
694
+ * live published row (`'active'`). Pass `'draft'` to read the pending
695
+ * unpublished revision (if any).
678
696
  */
679
- async get(ref) {
697
+ async get(ref, opts) {
680
698
  this.assertOpen();
699
+ const state = opts?.state ?? "active";
681
700
  const row = await this.engine.findOne("sys_metadata", {
682
- where: this.whereFor(ref)
701
+ where: this.whereFor(ref, state)
683
702
  });
684
703
  if (!row) return null;
685
704
  return this.rowToItem(ref, row);
@@ -722,12 +741,13 @@ var SysMetadataRepository = class {
722
741
  }
723
742
  async put(ref, spec, opts) {
724
743
  this.assertOpen();
725
- this.assertAllowed(ref.type);
744
+ this.assertAllowed(ref.type, opts.intent);
745
+ const state = opts.state ?? "active";
726
746
  const body = spec ?? {};
727
747
  const hash = hashSpec(body);
728
748
  const result = await this.withTxn(async (ctx) => {
729
749
  const existing = await this.engine.findOne("sys_metadata", {
730
- where: this.whereFor(ref),
750
+ where: this.whereFor(ref, state),
731
751
  context: ctx
732
752
  });
733
753
  const existingHash = existing?.checksum ?? null;
@@ -739,7 +759,8 @@ var SysMetadataRepository = class {
739
759
  return { skipped: true, version: hash, seq: item2.seq, item: item2 };
740
760
  }
741
761
  const now = (/* @__PURE__ */ new Date()).toISOString();
742
- const op = existing ? "update" : "create";
762
+ const baseOp = existing ? "update" : "create";
763
+ const op = opts.opType ?? baseOp;
743
764
  const version = await this.nextItemVersion(ref, ctx);
744
765
  const eventSeq = await this.nextEventSeq(ctx);
745
766
  const parentRowData = {
@@ -748,7 +769,7 @@ var SysMetadataRepository = class {
748
769
  organization_id: this.organizationId,
749
770
  metadata: JSON.stringify(body),
750
771
  checksum: hash,
751
- state: "active",
772
+ state,
752
773
  version,
753
774
  updated_at: now
754
775
  };
@@ -814,25 +835,28 @@ var SysMetadataRepository = class {
814
835
  return { version: result.version, seq: result.seq, item: result.item };
815
836
  }
816
837
  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
- });
838
+ if (state === "active") {
839
+ this.broadcast({
840
+ seq: result.seq,
841
+ op: result.op,
842
+ ref: this.fullRef(ref),
843
+ hash: result.version,
844
+ parentHash: result.existingHash,
845
+ actor: result.actor,
846
+ message: result.message,
847
+ ts: result.now,
848
+ source: result.source
849
+ });
850
+ }
828
851
  return { version: result.version, seq: result.seq, item: result.item };
829
852
  }
830
853
  async delete(ref, opts) {
831
854
  this.assertOpen();
832
- this.assertAllowed(ref.type);
855
+ this.assertAllowed(ref.type, opts.intent);
856
+ const state = opts.state ?? "active";
833
857
  const result = await this.withTxn(async (ctx) => {
834
858
  const existing = await this.engine.findOne("sys_metadata", {
835
- where: this.whereFor(ref),
859
+ where: this.whereFor(ref, state),
836
860
  context: ctx
837
861
  });
838
862
  if (!existing) {
@@ -849,32 +873,38 @@ var SysMetadataRepository = class {
849
873
  );
850
874
  }
851
875
  const now = (/* @__PURE__ */ new Date()).toISOString();
852
- const version = await this.nextItemVersion(ref, ctx);
853
- const eventSeq = await this.nextEventSeq(ctx);
876
+ let version = 0;
877
+ let eventSeq = 0;
878
+ if (state === "active") {
879
+ version = await this.nextItemVersion(ref, ctx);
880
+ eventSeq = await this.nextEventSeq(ctx);
881
+ }
854
882
  await this.engine.delete("sys_metadata", {
855
883
  where: { id: existingId },
856
884
  context: ctx
857
885
  });
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
- );
886
+ if (state === "active") {
887
+ await this.engine.insert(
888
+ this.historyTable,
889
+ {
890
+ id: this.uuid(),
891
+ event_seq: eventSeq,
892
+ type: ref.type,
893
+ name: ref.name,
894
+ version,
895
+ operation_type: "delete",
896
+ metadata: null,
897
+ checksum: null,
898
+ previous_checksum: existingHash,
899
+ change_note: opts.message,
900
+ source: opts.source ?? "sys-metadata-repo",
901
+ organization_id: this.organizationId,
902
+ recorded_by: opts.actor,
903
+ recorded_at: now
904
+ },
905
+ { context: ctx }
906
+ );
907
+ }
878
908
  return {
879
909
  eventSeq,
880
910
  existingHash,
@@ -884,20 +914,117 @@ var SysMetadataRepository = class {
884
914
  actor: opts.actor
885
915
  };
886
916
  });
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
- });
917
+ if (state === "active") {
918
+ this.seqCounter = result.eventSeq;
919
+ this.broadcast({
920
+ seq: result.eventSeq,
921
+ op: "delete",
922
+ ref: this.fullRef(ref),
923
+ hash: null,
924
+ parentHash: result.existingHash,
925
+ actor: result.actor,
926
+ message: result.message,
927
+ ts: result.now,
928
+ source: result.source
929
+ });
930
+ }
899
931
  return { seq: result.eventSeq };
900
932
  }
933
+ /**
934
+ * Promote the pending draft row for `ref` into the live (`active`)
935
+ * overlay. Atomic: reads the draft inside the same transaction, runs
936
+ * the canonical `put` to upsert the active row (which appends a
937
+ * history event with `operation_type='publish'`), then deletes the
938
+ * draft row.
939
+ *
940
+ * Errors if no draft exists (callers should 404). The active row's
941
+ * `parentVersion` is computed from the current active hash so this
942
+ * also surfaces optimistic-lock conflicts when something else has
943
+ * published in between (e.g. another admin reverted to an older
944
+ * version since the draft was authored).
945
+ */
946
+ async promoteDraft(ref, opts) {
947
+ this.assertOpen();
948
+ const draft = await this.get(ref, { state: "draft" });
949
+ if (!draft) {
950
+ const err = new Error(
951
+ `[no_draft] No pending draft exists for ${ref.type}/${ref.name} \u2014 nothing to publish.`
952
+ );
953
+ err.code = "no_draft";
954
+ err.status = 404;
955
+ throw err;
956
+ }
957
+ const currentActive = await this.get(ref, { state: "active" });
958
+ const result = await this.put(ref, draft.body, {
959
+ parentVersion: currentActive?.hash ?? null,
960
+ actor: opts.actor,
961
+ source: opts.source ?? "sys-metadata-repo.publish",
962
+ message: opts.message ?? `publish draft (hash ${draft.hash})`,
963
+ intent: opts.intent ?? "override-artifact",
964
+ state: "active",
965
+ opType: "publish"
966
+ });
967
+ try {
968
+ await this.delete(ref, {
969
+ parentVersion: draft.hash,
970
+ actor: opts.actor,
971
+ source: opts.source ?? "sys-metadata-repo.publish",
972
+ intent: opts.intent ?? "override-artifact",
973
+ state: "draft"
974
+ });
975
+ } catch {
976
+ }
977
+ return result;
978
+ }
979
+ /**
980
+ * Restore the body recorded in history at `targetVersion` (per-org
981
+ * lineage counter) as the new active row. Writes a history event
982
+ * with `operation_type='revert'` so the audit trail captures the
983
+ * intent. Does NOT touch any draft row.
984
+ *
985
+ * Throws `[version_not_found]` (404) if the target version row is
986
+ * missing or is a delete tombstone (no body to restore).
987
+ */
988
+ async restoreVersion(ref, targetVersion, opts) {
989
+ this.assertOpen();
990
+ const full = this.fullRef(ref);
991
+ const row = await this.engine.findOne(this.historyTable, {
992
+ where: {
993
+ organization_id: this.organizationId,
994
+ type: full.type,
995
+ name: full.name,
996
+ version: targetVersion
997
+ }
998
+ });
999
+ if (!row) {
1000
+ const err = new Error(
1001
+ `[version_not_found] No history row at version ${targetVersion} for ${ref.type}/${ref.name}.`
1002
+ );
1003
+ err.code = "version_not_found";
1004
+ err.status = 404;
1005
+ throw err;
1006
+ }
1007
+ const raw = row.metadata;
1008
+ if (raw === null || raw === void 0) {
1009
+ const err = new Error(
1010
+ `[version_not_restorable] Version ${targetVersion} for ${ref.type}/${ref.name} is a delete tombstone \u2014 nothing to restore.`
1011
+ );
1012
+ err.code = "version_not_restorable";
1013
+ err.status = 409;
1014
+ throw err;
1015
+ }
1016
+ const body = typeof raw === "string" ? JSON.parse(raw) : raw;
1017
+ const currentActive = await this.get(ref, { state: "active" });
1018
+ return this.put(ref, body, {
1019
+ parentVersion: currentActive?.hash ?? null,
1020
+ actor: opts.actor,
1021
+ source: opts.source ?? "sys-metadata-repo.revert",
1022
+ message: opts.message ?? `revert to version ${targetVersion}`,
1023
+ intent: opts.intent ?? "override-artifact",
1024
+ state: "active",
1025
+ opType: "revert"
1026
+ });
1027
+ }
901
1028
  async *list(filter) {
902
1029
  this.assertOpen();
903
1030
  const where = {
@@ -952,6 +1079,7 @@ var SysMetadataRepository = class {
952
1079
  ref: full,
953
1080
  hash: row.checksum ?? null,
954
1081
  parentHash: row.previous_checksum ?? null,
1082
+ version: typeof row.version === "number" ? row.version : void 0,
955
1083
  actor: row.recorded_by ?? "unknown",
956
1084
  message: row.change_note ?? void 0,
957
1085
  ts: row.recorded_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
@@ -1033,29 +1161,52 @@ var SysMetadataRepository = class {
1033
1161
  assertOpen() {
1034
1162
  if (this.closed) throw new Error("SysMetadataRepository is closed");
1035
1163
  }
1036
- assertAllowed(type) {
1164
+ /**
1165
+ * Defense-in-depth authorization gate.
1166
+ *
1167
+ * `intent` defaults to `'override-artifact'` (the historical strict
1168
+ * behavior). The protocol layer passes `'runtime-only'` after it has
1169
+ * verified — via the schema registry — that no artifact item exists
1170
+ * at `(type, name)`. In that case we accept types with
1171
+ * `allowRuntimeCreate: true`, even when `allowOrgOverride` is false.
1172
+ *
1173
+ * The env-var escape hatch (`OBJECTSTACK_METADATA_WRITABLE`) still
1174
+ * applies to BOTH intents, so operators can opt into artifact
1175
+ * overrides at runtime for emergency fixes.
1176
+ */
1177
+ assertAllowed(type, intent = "override-artifact") {
1037
1178
  const singular = PLURAL_TO_SINGULAR[type] ?? type;
1038
1179
  const allowedByRegistry = OVERLAY_ALLOWED_TYPES.has(singular) || OVERLAY_ALLOWED_TYPES.has(type);
1039
1180
  if (allowedByRegistry) return;
1181
+ if (intent === "runtime-only") {
1182
+ if (RUNTIME_CREATE_ALLOWED_TYPES.has(singular) || RUNTIME_CREATE_ALLOWED_TYPES.has(type)) {
1183
+ return;
1184
+ }
1185
+ if (!STATIC_REGISTRY_TYPES.has(singular) && !STATIC_REGISTRY_TYPES.has(type)) {
1186
+ return;
1187
+ }
1188
+ }
1040
1189
  const env = envWritableMetadataTypes();
1041
1190
  if (env.has(singular) || env.has(type)) return;
1042
1191
  const allowed = [
1043
1192
  ...OVERLAY_ALLOWED_TYPES,
1044
1193
  ...envWritableMetadataTypes()
1045
1194
  ];
1195
+ const code = intent === "runtime-only" ? "not_creatable" : "not_overridable";
1196
+ const detail = intent === "runtime-only" ? `'${type}' has neither allowOrgOverride nor allowRuntimeCreate in the registry. ` : `'${type}' is not allowOrgOverride in the registry. `;
1046
1197
  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.`
1198
+ `[${code}] ${detail}Overlay-allowed: ${Array.from(new Set(allowed)).join(", ") || "(none)"}. Set OBJECTSTACK_METADATA_WRITABLE to enable additional types at runtime.`
1048
1199
  );
1049
- err.code = "not_overridable";
1200
+ err.code = code;
1050
1201
  err.status = 403;
1051
1202
  throw err;
1052
1203
  }
1053
- whereFor(ref) {
1204
+ whereFor(ref, state = "active") {
1054
1205
  return {
1055
1206
  type: ref.type,
1056
1207
  name: ref.name,
1057
1208
  organization_id: this.organizationId,
1058
- state: "active"
1209
+ state
1059
1210
  };
1060
1211
  }
1061
1212
  fullRef(ref) {
@@ -1151,37 +1302,58 @@ var SysMetadataRepository = class {
1151
1302
 
1152
1303
  // src/protocol.ts
1153
1304
  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";
1305
+ import { parseFilterAST, isFilterAST } from "@objectstack/spec/data";
1306
+ import { PLURAL_TO_SINGULAR as PLURAL_TO_SINGULAR3, SINGULAR_TO_PLURAL as SINGULAR_TO_PLURAL2 } from "@objectstack/spec/shared";
1307
+ import { METADATA_FORM_REGISTRY } from "@objectstack/spec/system";
1308
+ import { DEFAULT_METADATA_TYPE_REGISTRY as DEFAULT_METADATA_TYPE_REGISTRY2, getMetadataTypeSchema as getMetadataTypeSchema2 } from "@objectstack/spec/kernel";
1163
1309
  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
- };
1310
+
1311
+ // src/metadata-diagnostics.ts
1312
+ import { getMetadataTypeSchema } from "@objectstack/spec/kernel";
1313
+ import { PLURAL_TO_SINGULAR as PLURAL_TO_SINGULAR2 } from "@objectstack/spec/shared";
1314
+ function computeMetadataDiagnostics(type, item) {
1315
+ const singular = PLURAL_TO_SINGULAR2[type] ?? type;
1316
+ const schema = getMetadataTypeSchema(singular);
1317
+ if (!schema) return void 0;
1318
+ if (item === null || item === void 0 || typeof item !== "object") {
1319
+ return {
1320
+ valid: false,
1321
+ errors: [{
1322
+ path: "",
1323
+ message: "Metadata document must be a non-null object",
1324
+ code: "invalid_type"
1325
+ }]
1326
+ };
1327
+ }
1328
+ const candidate = "_diagnostics" in item ? stripDiagnostics(item) : item;
1329
+ const parsed = schema.safeParse(candidate);
1330
+ if (parsed.success) {
1331
+ return { valid: true };
1332
+ }
1333
+ const errors = parsed.error.issues.map((issue) => ({
1334
+ path: issue.path.map(String).join("."),
1335
+ message: issue.message,
1336
+ code: issue.code
1337
+ }));
1338
+ return { valid: false, errors };
1339
+ }
1340
+ function stripDiagnostics(item) {
1341
+ const { _diagnostics: _drop, ...rest } = item;
1342
+ void _drop;
1343
+ return rest;
1344
+ }
1345
+ function decorateMetadataItem(type, item) {
1346
+ if (!item || typeof item !== "object") return item;
1347
+ const diagnostics = computeMetadataDiagnostics(type, item);
1348
+ if (!diagnostics) return item;
1349
+ return { ...item, _diagnostics: diagnostics };
1350
+ }
1351
+ function decorateMetadataItems(type, items) {
1352
+ if (!Array.isArray(items)) return items;
1353
+ return items.map((item) => decorateMetadataItem(type, item));
1354
+ }
1355
+
1356
+ // src/protocol.ts
1185
1357
  var TYPE_TO_FORM = METADATA_FORM_REGISTRY;
1186
1358
  var _jsonSchemaCache = /* @__PURE__ */ new WeakMap();
1187
1359
  function toJsonSchemaSafe(schema) {
@@ -1211,9 +1383,17 @@ var HAND_CRAFTED_SCHEMAS = {
1211
1383
  abstract: { type: "boolean", default: false },
1212
1384
  datasource: { type: "string" },
1213
1385
  fields: {
1214
- type: "array",
1215
- default: [],
1216
- items: {
1386
+ // Canonical Object.fields is a name-keyed map
1387
+ // (Record<string, FieldDefinition>) — insertion order is
1388
+ // display order. The SchemaForm engine recognises
1389
+ // `additionalProperties` as a Record and dispatches to
1390
+ // the `record` form-field renderer (ADR-0007). The form
1391
+ // layout in `object.form.ts` declares `type: 'record'`
1392
+ // so the inner `additionalProperties` schema is used to
1393
+ // shape each value.
1394
+ type: "object",
1395
+ default: {},
1396
+ additionalProperties: {
1217
1397
  type: "object",
1218
1398
  properties: {
1219
1399
  name: { type: "string" },
@@ -1224,7 +1404,7 @@ var HAND_CRAFTED_SCHEMAS = {
1224
1404
  defaultValue: {},
1225
1405
  description: { type: "string" }
1226
1406
  },
1227
- required: ["name", "type"]
1407
+ required: ["type"]
1228
1408
  }
1229
1409
  },
1230
1410
  capabilities: { type: "object", additionalProperties: true }
@@ -1369,19 +1549,9 @@ var HAND_CRAFTED_SCHEMAS = {
1369
1549
  additionalProperties: true
1370
1550
  }
1371
1551
  };
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
- }
1552
+ function resolveOverlaySchema(type, _item) {
1553
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
1554
+ return getMetadataTypeSchema2(singular) ?? null;
1385
1555
  }
1386
1556
  function simpleHash(str) {
1387
1557
  let hash = 0;
@@ -1514,6 +1684,32 @@ function extractPathValues(item, path) {
1514
1684
  }
1515
1685
  return out;
1516
1686
  }
1687
+ function diffShallow(from, to) {
1688
+ const added = [];
1689
+ const removed = [];
1690
+ const changed = [];
1691
+ const fromKeys = new Set(Object.keys(from ?? {}));
1692
+ const toKeys = new Set(Object.keys(to ?? {}));
1693
+ for (const k of toKeys) {
1694
+ if (!fromKeys.has(k)) {
1695
+ added.push({ path: k, value: to[k] });
1696
+ } else {
1697
+ const a = from[k];
1698
+ const b = to[k];
1699
+ const aStr = JSON.stringify(a);
1700
+ const bStr = JSON.stringify(b);
1701
+ if (aStr !== bStr) {
1702
+ changed.push({ path: k, from: a, to: b });
1703
+ }
1704
+ }
1705
+ }
1706
+ for (const k of fromKeys) {
1707
+ if (!toKeys.has(k)) {
1708
+ removed.push({ path: k, value: from[k] });
1709
+ }
1710
+ }
1711
+ return { added, removed, changed };
1712
+ }
1517
1713
  function detectDestructiveObjectChanges(prev, next) {
1518
1714
  if (!prev || typeof prev !== "object" || !next || typeof next !== "object") return [];
1519
1715
  const prevFields = prev.fields && typeof prev.fields === "object" ? prev.fields : {};
@@ -1645,6 +1841,20 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1645
1841
  }
1646
1842
  }
1647
1843
  }
1844
+ const draftPartialSql = "CREATE UNIQUE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft ON sys_metadata (type, name, organization_id) WHERE state = 'draft'";
1845
+ try {
1846
+ await exec(draftPartialSql);
1847
+ } catch (err) {
1848
+ const msg = err instanceof Error ? err.message : String(err);
1849
+ if (/partial|where clause|syntax/i.test(msg)) {
1850
+ try {
1851
+ await exec(
1852
+ "CREATE INDEX IF NOT EXISTS idx_sys_metadata_overlay_draft ON sys_metadata (type, name, organization_id)"
1853
+ );
1854
+ } catch {
1855
+ }
1856
+ }
1857
+ }
1648
1858
  } catch {
1649
1859
  }
1650
1860
  }
@@ -1768,8 +1978,8 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1768
1978
  DEFAULT_METADATA_TYPE_REGISTRY2.map((e) => [e.type, e])
1769
1979
  );
1770
1980
  const entries = allTypes.map((type) => {
1771
- const singular = PLURAL_TO_SINGULAR2[type] ?? type;
1772
- const zodSchema = singular === "view" ? ListViewSchema : TYPE_TO_SCHEMA[singular];
1981
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
1982
+ const zodSchema = getMetadataTypeSchema2(singular);
1773
1983
  const schema = (zodSchema ? toJsonSchemaSafe(zodSchema) : void 0) ?? HAND_CRAFTED_SCHEMAS[singular];
1774
1984
  const form = TYPE_TO_FORM[singular];
1775
1985
  const base = registryByType.get(singular);
@@ -1810,19 +2020,76 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1810
2020
  });
1811
2021
  return { types: allTypes, entries };
1812
2022
  }
2023
+ /**
2024
+ * Sweep all (or filtered) metadata types and report entries that
2025
+ * fail spec validation. Powers the Studio governance view
2026
+ * (`GET /api/v1/meta/diagnostics`) and `os doctor`-style CLI
2027
+ * checks.
2028
+ *
2029
+ * `severity` defaults to `'error'` — only entries with at least
2030
+ * one Zod error issue are returned. `'warning'` includes
2031
+ * everything we surface (warnings are reserved for a future lint
2032
+ * layer on top of spec validation).
2033
+ *
2034
+ * `type` may be either a singular (`'view'`) or plural (`'views'`)
2035
+ * identifier; the underlying `getMetaItems` already normalises.
2036
+ *
2037
+ * Implementation note: leverages the `_diagnostics` already
2038
+ * decorated onto items by `getMetaItems()` to avoid running
2039
+ * `safeParse()` twice. For types whose schema is unregistered we
2040
+ * skip silently (they cannot be validated and should not appear
2041
+ * as "valid" either — they are simply opaque to this report).
2042
+ */
2043
+ async getMetaDiagnostics(request = {}) {
2044
+ const includeWarnings = request.severity === "warning";
2045
+ const targetTypes = request.type ? [request.type] : DEFAULT_METADATA_TYPE_REGISTRY2.filter((e) => getMetadataTypeSchema2(e.type)).map((e) => e.type);
2046
+ const entries = [];
2047
+ let scannedItems = 0;
2048
+ for (const t of targetTypes) {
2049
+ let listed;
2050
+ try {
2051
+ listed = await this.getMetaItems({
2052
+ type: t,
2053
+ organizationId: request.organizationId,
2054
+ packageId: request.packageId
2055
+ });
2056
+ } catch {
2057
+ continue;
2058
+ }
2059
+ const items = Array.isArray(listed?.items) ? listed.items : Array.isArray(listed) ? listed : [];
2060
+ for (const item of items) {
2061
+ scannedItems += 1;
2062
+ const diag = item?._diagnostics ?? computeMetadataDiagnostics(t, item);
2063
+ if (!diag) continue;
2064
+ if (diag.valid && !includeWarnings) continue;
2065
+ if (diag.valid && includeWarnings && !diag.warnings?.length) continue;
2066
+ entries.push({
2067
+ type: t,
2068
+ name: typeof item?.name === "string" ? item.name : "<unknown>",
2069
+ diagnostics: diag
2070
+ });
2071
+ }
2072
+ }
2073
+ return {
2074
+ entries,
2075
+ total: entries.length,
2076
+ scannedTypes: targetTypes.length,
2077
+ scannedItems
2078
+ };
2079
+ }
1813
2080
  async getMetaItems(request) {
1814
2081
  const { packageId } = request;
1815
2082
  let items = [];
1816
2083
  if (this.environmentId === void 0) {
1817
2084
  items = [...this.engine.registry.listItems(request.type, packageId)];
1818
2085
  if (items.length === 0) {
1819
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2086
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1820
2087
  if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
1821
2088
  }
1822
2089
  } else {
1823
2090
  items = [...this.engine.registry.listItems(request.type, packageId)];
1824
2091
  if (items.length === 0) {
1825
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2092
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1826
2093
  if (alt) items = [...this.engine.registry.listItems(alt, packageId)];
1827
2094
  }
1828
2095
  }
@@ -1837,7 +2104,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1837
2104
  if (packageId) whereClause._packageId = packageId;
1838
2105
  let rs = await this.engine.find("sys_metadata", { where: whereClause });
1839
2106
  if (!rs || rs.length === 0) {
1840
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2107
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1841
2108
  if (alt) {
1842
2109
  const altWhere = { type: alt, state: "active", organization_id: oid };
1843
2110
  if (packageId) altWhere._packageId = packageId;
@@ -1904,28 +2171,29 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1904
2171
  }
1905
2172
  return {
1906
2173
  type: request.type,
1907
- items
2174
+ items: decorateMetadataItems(request.type, items)
1908
2175
  };
1909
2176
  }
1910
2177
  async getMetaItem(request) {
1911
2178
  let item;
1912
2179
  const orgId = request.organizationId;
2180
+ const readState = request.state === "draft" ? "draft" : "active";
1913
2181
  try {
1914
2182
  const findOverlay = async (oid) => {
1915
2183
  const where = {
1916
2184
  type: request.type,
1917
2185
  name: request.name,
1918
- state: "active",
2186
+ state: readState,
1919
2187
  organization_id: oid
1920
2188
  };
1921
2189
  const rec = await this.engine.findOne("sys_metadata", { where });
1922
2190
  if (rec) return rec;
1923
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2191
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1924
2192
  if (alt) {
1925
2193
  const altWhere = {
1926
2194
  type: alt,
1927
2195
  name: request.name,
1928
- state: "active",
2196
+ state: readState,
1929
2197
  organization_id: oid
1930
2198
  };
1931
2199
  return await this.engine.findOne("sys_metadata", { where: altWhere });
@@ -1938,6 +2206,17 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1938
2206
  }
1939
2207
  } catch {
1940
2208
  }
2209
+ if (readState === "draft") {
2210
+ if (item === void 0) {
2211
+ const err = new Error(
2212
+ `[no_draft] No pending draft exists for ${request.type}/${request.name}.`
2213
+ );
2214
+ err.code = "no_draft";
2215
+ err.status = 404;
2216
+ throw err;
2217
+ }
2218
+ return { type: request.type, name: request.name, item: decorateMetadataItem(request.type, item) };
2219
+ }
1941
2220
  if (item === void 0) {
1942
2221
  try {
1943
2222
  const services = this.getServicesRegistry?.();
@@ -1947,7 +2226,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1947
2226
  if (fromService !== void 0 && fromService !== null) {
1948
2227
  item = fromService;
1949
2228
  } else {
1950
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2229
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1951
2230
  if (alt) {
1952
2231
  const altFromService = await metadataService.get(alt, request.name);
1953
2232
  if (altFromService !== void 0 && altFromService !== null) {
@@ -1962,14 +2241,14 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1962
2241
  if (item === void 0) {
1963
2242
  item = this.engine.registry.getItem(request.type, request.name);
1964
2243
  if (item === void 0) {
1965
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2244
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1966
2245
  if (alt) item = this.engine.registry.getItem(alt, request.name);
1967
2246
  }
1968
2247
  }
1969
2248
  return {
1970
2249
  type: request.type,
1971
2250
  name: request.name,
1972
- item
2251
+ item: decorateMetadataItem(request.type, item)
1973
2252
  };
1974
2253
  }
1975
2254
  /**
@@ -1995,7 +2274,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
1995
2274
  if (metadataService && typeof metadataService.get === "function") {
1996
2275
  let fromService = await metadataService.get(request.type, request.name);
1997
2276
  if (fromService === void 0 || fromService === null) {
1998
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2277
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
1999
2278
  if (alt) fromService = await metadataService.get(alt, request.name);
2000
2279
  }
2001
2280
  if (fromService !== void 0 && fromService !== null) code = fromService;
@@ -2005,7 +2284,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2005
2284
  if (code === null) {
2006
2285
  let regItem = this.engine.registry.getItem(request.type, request.name);
2007
2286
  if (regItem === void 0) {
2008
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2287
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2009
2288
  if (alt) regItem = this.engine.registry.getItem(alt, request.name);
2010
2289
  }
2011
2290
  if (regItem !== void 0) code = regItem;
@@ -2022,7 +2301,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2022
2301
  };
2023
2302
  let rec = await this.engine.findOne("sys_metadata", { where });
2024
2303
  if (!rec) {
2025
- const alt = PLURAL_TO_SINGULAR2[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2304
+ const alt = PLURAL_TO_SINGULAR3[request.type] ?? SINGULAR_TO_PLURAL2[request.type];
2026
2305
  if (alt) {
2027
2306
  rec = await this.engine.findOne("sys_metadata", {
2028
2307
  where: { ...where, type: alt }
@@ -2048,13 +2327,15 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2048
2327
  } catch {
2049
2328
  }
2050
2329
  const effective = overlay ?? code;
2330
+ const _diagnostics = effective !== null && effective !== void 0 ? computeMetadataDiagnostics(request.type, effective) : void 0;
2051
2331
  return {
2052
2332
  type: request.type,
2053
2333
  name: request.name,
2054
2334
  code,
2055
2335
  overlay,
2056
2336
  overlayScope,
2057
- effective
2337
+ effective,
2338
+ ..._diagnostics ? { _diagnostics } : {}
2058
2339
  };
2059
2340
  }
2060
2341
  async getUiView(request) {
@@ -2935,7 +3216,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2935
3216
  for (const tok of raw.split(",")) {
2936
3217
  const t = tok.trim();
2937
3218
  if (!t) continue;
2938
- const singular = PLURAL_TO_SINGULAR2[t] ?? t;
3219
+ const singular = PLURAL_TO_SINGULAR3[t] ?? t;
2939
3220
  set.add(singular);
2940
3221
  const plural = SINGULAR_TO_PLURAL2[singular];
2941
3222
  if (plural) set.add(plural);
@@ -2949,13 +3230,49 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2949
3230
  }
2950
3231
  /** Normalize plural→singular before consulting the allow-list. */
2951
3232
  static isOverlayAllowed(type) {
2952
- const singular = PLURAL_TO_SINGULAR2[type] ?? type;
3233
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
2953
3234
  if (this.OVERLAY_ALLOWED_TYPES.has(singular) || this.OVERLAY_ALLOWED_TYPES.has(type)) {
2954
3235
  return true;
2955
3236
  }
2956
3237
  const env = this.envWritableTypes();
2957
3238
  return env.has(singular) || env.has(type);
2958
3239
  }
3240
+ /** Does this type permit creating brand-new (artifact-free) items? */
3241
+ static isRuntimeCreateAllowed(type) {
3242
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3243
+ if (this.RUNTIME_CREATE_ALLOWED_TYPES.has(singular) || this.RUNTIME_CREATE_ALLOWED_TYPES.has(type)) {
3244
+ return true;
3245
+ }
3246
+ if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) {
3247
+ return true;
3248
+ }
3249
+ return false;
3250
+ }
3251
+ /**
3252
+ * Does an artifact (npm-package-loaded) item exist at `(type, name)`?
3253
+ *
3254
+ * The schema registry's `_packageId` tag is set only when
3255
+ * `registerItem(..., packageId)` is called with a truthy packageId
3256
+ * — and only artifact loaders do that. DB-rehydrated items
3257
+ * (sys_metadata rows registered back into the registry by
3258
+ * `getMetaItems` / `loadMetaFromDb`) call `registerItem` without a
3259
+ * packageId, so they carry no `_packageId` and are correctly
3260
+ * excluded here.
3261
+ *
3262
+ * Used by the two-tier authorization model to distinguish
3263
+ * "overlaying a packaged item" (requires `allowOrgOverride`) from
3264
+ * "authoring a DB-only item" (requires only `allowRuntimeCreate`).
3265
+ */
3266
+ isArtifactBacked(type, name) {
3267
+ const registry = this.engine?.registry;
3268
+ if (!registry || typeof registry.getItem !== "function") {
3269
+ return false;
3270
+ }
3271
+ const singular = PLURAL_TO_SINGULAR3[type] ?? type;
3272
+ const item = registry.getItem(singular, name) ?? registry.getItem(type, name);
3273
+ if (!item || !item._packageId) return false;
3274
+ return item._packageId !== "sys_metadata";
3275
+ }
2959
3276
  /**
2960
3277
  * Mirror an object-type overlay write into the in-memory engine
2961
3278
  * registry so subsequent CRUD finds the new schema. Idempotent and
@@ -2981,16 +3298,29 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
2981
3298
  if (!request.item) {
2982
3299
  throw new Error("Item data is required");
2983
3300
  }
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;
3301
+ const mode = request.mode === "draft" ? "draft" : "publish";
3302
+ if (this.environmentId !== void 0) {
3303
+ const overlayAllowed = _ObjectStackProtocolImplementation.isOverlayAllowed(request.type);
3304
+ const runtimeCreateAllowed = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(request.type);
3305
+ const artifactBacked = this.isArtifactBacked(request.type, request.name);
3306
+ if (artifactBacked && !overlayAllowed) {
3307
+ const err = new Error(
3308
+ `[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.`
3309
+ );
3310
+ err.code = "not_overridable";
3311
+ err.status = 403;
3312
+ throw err;
3313
+ }
3314
+ if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) {
3315
+ const err = new Error(
3316
+ `[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.`
3317
+ );
3318
+ err.code = "not_creatable";
3319
+ err.status = 403;
3320
+ throw err;
3321
+ }
2992
3322
  }
2993
- const singularType = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3323
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
2994
3324
  if (!request.force && (singularType === "object" || singularType === "field")) {
2995
3325
  try {
2996
3326
  const existing = await this.getMetaItem({
@@ -3038,8 +3368,13 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3038
3368
  }
3039
3369
  }
3040
3370
  await this.ensureOverlayIndex();
3041
- const singularTypeForRepo = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3042
- if (_ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo)) {
3371
+ const singularTypeForRepo = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3372
+ const overlayAllowedForRepo = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
3373
+ const runtimeCreateAllowedForRepo = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularTypeForRepo);
3374
+ const useRepoPath = overlayAllowedForRepo || runtimeCreateAllowedForRepo;
3375
+ if (useRepoPath) {
3376
+ const artifactBacked = this.isArtifactBacked(singularTypeForRepo, request.name);
3377
+ const intent = artifactBacked ? "override-artifact" : "runtime-only";
3043
3378
  const orgId = request.organizationId ?? null;
3044
3379
  const repo = this.getOverlayRepo(orgId);
3045
3380
  const ref = {
@@ -3051,21 +3386,26 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3051
3386
  if (request.parentVersion !== void 0) {
3052
3387
  parentVersion = request.parentVersion;
3053
3388
  } else {
3054
- const current = await repo.get(ref);
3389
+ const current = await repo.get(ref, { state: mode === "draft" ? "draft" : "active" });
3055
3390
  parentVersion = current?.hash ?? null;
3056
3391
  }
3057
3392
  try {
3058
3393
  const result = await repo.put(ref, request.item, {
3059
3394
  parentVersion,
3060
3395
  actor: request.actor ?? "system",
3061
- source: "protocol.saveMetaItem"
3396
+ source: "protocol.saveMetaItem",
3397
+ intent,
3398
+ state: mode === "draft" ? "draft" : "active"
3062
3399
  });
3063
- this.applyObjectRegistryMutation(request);
3400
+ if (mode === "publish") {
3401
+ this.applyObjectRegistryMutation(request);
3402
+ }
3064
3403
  return {
3065
3404
  success: true,
3066
3405
  version: result.version,
3067
3406
  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}]`
3407
+ state: mode === "draft" ? "draft" : "active",
3408
+ 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
3409
  };
3070
3410
  } catch (err) {
3071
3411
  if (err instanceof ConflictError2) {
@@ -3149,8 +3489,8 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3149
3489
  * "no history" uniformly.
3150
3490
  */
3151
3491
  async historyMetaItem(request) {
3152
- const singularType = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3153
- if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType)) {
3492
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3493
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !_ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) {
3154
3494
  return { events: [] };
3155
3495
  }
3156
3496
  const orgId = request.organizationId ?? null;
@@ -3168,22 +3508,238 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3168
3508
  return { events };
3169
3509
  }
3170
3510
  /**
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}.
3511
+ * Promote the pending draft overlay to the live (`active`) row.
3512
+ * Records a history event with `op='publish'`. 404 (`[no_draft]`)
3513
+ * when there is nothing to publish.
3175
3514
  */
3176
- async deleteMetaItem(request) {
3177
- if (this.environmentId !== void 0 && !_ObjectStackProtocolImplementation.isOverlayAllowed(request.type)) {
3515
+ async publishMetaItem(request) {
3516
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3517
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !_ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) {
3518
+ const err = new Error(
3519
+ `[not_overridable] Metadata type '${request.type}' is not draftable \u2014 no overlay/runtime-create permission.`
3520
+ );
3521
+ err.code = "not_overridable";
3522
+ err.status = 403;
3523
+ throw err;
3524
+ }
3525
+ await this.ensureOverlayIndex();
3526
+ const orgId = request.organizationId ?? null;
3527
+ const repo = this.getOverlayRepo(orgId);
3528
+ const artifactBacked = this.isArtifactBacked(singularType, request.name);
3529
+ const intent = artifactBacked ? "override-artifact" : "runtime-only";
3530
+ const ref = {
3531
+ type: singularType,
3532
+ name: request.name,
3533
+ org: orgId ?? "env"
3534
+ };
3535
+ try {
3536
+ const result = await repo.promoteDraft(ref, {
3537
+ actor: request.actor ?? "system",
3538
+ source: "protocol.publishMetaItem",
3539
+ ...request.message ? { message: request.message } : {},
3540
+ intent
3541
+ });
3542
+ this.applyObjectRegistryMutation({
3543
+ type: request.type,
3544
+ name: request.name,
3545
+ item: result.item.body
3546
+ });
3547
+ return {
3548
+ success: true,
3549
+ version: result.version,
3550
+ seq: result.seq,
3551
+ message: `Published draft \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
3552
+ };
3553
+ } catch (err) {
3554
+ if (err instanceof ConflictError2) {
3555
+ const conflict = new Error(
3556
+ `[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"}.`
3557
+ );
3558
+ conflict.code = "metadata_conflict";
3559
+ conflict.status = 409;
3560
+ conflict.expectedParent = err.expectedParent;
3561
+ conflict.actualHead = err.actualHead;
3562
+ throw conflict;
3563
+ }
3564
+ throw err;
3565
+ }
3566
+ }
3567
+ /**
3568
+ * Restore the body recorded at history `toVersion` as the new
3569
+ * live row. Writes a history event with `op='revert'`. 404
3570
+ * (`[version_not_found]`) when the target version doesn't exist;
3571
+ * 409 (`[version_not_restorable]`) when the target is a delete
3572
+ * tombstone (no body to bring back).
3573
+ */
3574
+ async rollbackMetaItem(request) {
3575
+ if (!Number.isFinite(request.toVersion) || request.toVersion < 1) {
3576
+ const err = new Error(
3577
+ `[invalid_request] rollbackMetaItem requires a positive integer 'toVersion' (got ${request.toVersion}).`
3578
+ );
3579
+ err.code = "invalid_request";
3580
+ err.status = 400;
3581
+ throw err;
3582
+ }
3583
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3584
+ if (!_ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !_ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) {
3178
3585
  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.`
3586
+ `[not_overridable] Metadata type '${request.type}' is not revertable \u2014 no overlay/runtime-create permission.`
3180
3587
  );
3181
3588
  err.code = "not_overridable";
3182
3589
  err.status = 403;
3183
3590
  throw err;
3184
3591
  }
3185
- const singularTypeForRepo = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3186
- const useRepoPath = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
3592
+ await this.ensureOverlayIndex();
3593
+ const orgId = request.organizationId ?? null;
3594
+ const repo = this.getOverlayRepo(orgId);
3595
+ const artifactBacked = this.isArtifactBacked(singularType, request.name);
3596
+ const intent = artifactBacked ? "override-artifact" : "runtime-only";
3597
+ const ref = {
3598
+ type: singularType,
3599
+ name: request.name,
3600
+ org: orgId ?? "env"
3601
+ };
3602
+ try {
3603
+ const result = await repo.restoreVersion(ref, request.toVersion, {
3604
+ actor: request.actor ?? "system",
3605
+ source: "protocol.rollbackMetaItem",
3606
+ ...request.message ? { message: request.message } : {},
3607
+ intent
3608
+ });
3609
+ this.applyObjectRegistryMutation({
3610
+ type: request.type,
3611
+ name: request.name,
3612
+ item: result.item.body
3613
+ });
3614
+ return {
3615
+ success: true,
3616
+ version: result.version,
3617
+ seq: result.seq,
3618
+ restoredFromVersion: request.toVersion,
3619
+ message: `Reverted to version ${request.toVersion} \u2014 type=${request.type}, name=${request.name} [seq=${result.seq}]`
3620
+ };
3621
+ } catch (err) {
3622
+ if (err instanceof ConflictError2) {
3623
+ const conflict = new Error(
3624
+ `[metadata_conflict] ${request.type}/${request.name} advanced during rollback. Expected parent ${err.expectedParent ?? "null"} but current is ${err.actualHead ?? "null"}.`
3625
+ );
3626
+ conflict.code = "metadata_conflict";
3627
+ conflict.status = 409;
3628
+ conflict.expectedParent = err.expectedParent;
3629
+ conflict.actualHead = err.actualHead;
3630
+ throw conflict;
3631
+ }
3632
+ throw err;
3633
+ }
3634
+ }
3635
+ /**
3636
+ * Compute a shallow structural diff between two historical
3637
+ * versions of a metadata item. Either side may be omitted: when
3638
+ * `toVersion` is undefined the current active body is used; when
3639
+ * `fromVersion` is undefined the immediately previous history row
3640
+ * is used. Returns `{ added, removed, changed }` keyed by JSON
3641
+ * pointer-style paths for primitive leaves; nested objects/arrays
3642
+ * are reported as a single change record.
3643
+ */
3644
+ async diffMetaItem(request) {
3645
+ const singularType = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3646
+ const orgId = request.organizationId ?? null;
3647
+ const events = (await this.historyMetaItem({
3648
+ type: singularType,
3649
+ name: request.name,
3650
+ ...orgId ? { organizationId: orgId } : {}
3651
+ })).events;
3652
+ const versions = events.map((ev) => ev.version).filter((v) => typeof v === "number");
3653
+ const repo = this.getOverlayRepo(orgId);
3654
+ const fullRef = {
3655
+ type: singularType,
3656
+ name: request.name,
3657
+ org: orgId ?? "env"
3658
+ };
3659
+ const histRows = [];
3660
+ try {
3661
+ const engineAny = this.engine;
3662
+ const rows = await engineAny.find("sys_metadata_history", {
3663
+ where: {
3664
+ organization_id: orgId,
3665
+ type: singularType,
3666
+ name: request.name
3667
+ }
3668
+ });
3669
+ rows.sort((a, b) => (a.version ?? 0) - (b.version ?? 0));
3670
+ for (const r of rows) {
3671
+ const body = r.metadata == null ? null : typeof r.metadata === "string" ? JSON.parse(r.metadata) : r.metadata;
3672
+ histRows.push({ version: r.version ?? 0, body });
3673
+ }
3674
+ } catch {
3675
+ }
3676
+ const byVersion = /* @__PURE__ */ new Map();
3677
+ for (const r of histRows) byVersion.set(r.version, r.body);
3678
+ let fromBody = null;
3679
+ let toBody = null;
3680
+ let fromVersion = null;
3681
+ let toVersion = null;
3682
+ if (request.toVersion !== void 0) {
3683
+ toVersion = request.toVersion;
3684
+ toBody = byVersion.get(request.toVersion) ?? null;
3685
+ } else {
3686
+ const current = await repo.get(fullRef, { state: "active" });
3687
+ toBody = current ? current.body : null;
3688
+ toVersion = histRows.length ? histRows[histRows.length - 1].version : null;
3689
+ }
3690
+ if (request.fromVersion !== void 0) {
3691
+ fromVersion = request.fromVersion;
3692
+ fromBody = byVersion.get(request.fromVersion) ?? null;
3693
+ } else if (toVersion !== null) {
3694
+ const sorted = histRows.map((r) => r.version).filter((v) => v < toVersion);
3695
+ if (sorted.length) {
3696
+ fromVersion = sorted[sorted.length - 1];
3697
+ fromBody = byVersion.get(fromVersion) ?? null;
3698
+ }
3699
+ }
3700
+ const diff = diffShallow(fromBody ?? {}, toBody ?? {});
3701
+ const _used = versions;
3702
+ void _used;
3703
+ return {
3704
+ type: request.type,
3705
+ name: request.name,
3706
+ fromVersion,
3707
+ toVersion,
3708
+ ...diff
3709
+ };
3710
+ }
3711
+ /**
3712
+ * Remove a customization overlay row for the given metadata item, so the
3713
+ * next read falls through to the artifact-loaded default. Implements the
3714
+ * "Reset to factory default" semantic from ADR-0005. Whitelist is shared
3715
+ * with {@link saveMetaItem}.
3716
+ */
3717
+ async deleteMetaItem(request) {
3718
+ if (this.environmentId !== void 0) {
3719
+ const overlayAllowed = _ObjectStackProtocolImplementation.isOverlayAllowed(request.type);
3720
+ const runtimeCreateAllowed = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(request.type);
3721
+ const artifactBacked = this.isArtifactBacked(request.type, request.name);
3722
+ if (artifactBacked && !overlayAllowed) {
3723
+ const err = new Error(
3724
+ `[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.`
3725
+ );
3726
+ err.code = "not_overridable";
3727
+ err.status = 403;
3728
+ throw err;
3729
+ }
3730
+ if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) {
3731
+ const err = new Error(
3732
+ `[not_creatable] Metadata type '${request.type}' does not allow runtime creation or deletion.`
3733
+ );
3734
+ err.code = "not_creatable";
3735
+ err.status = 403;
3736
+ throw err;
3737
+ }
3738
+ }
3739
+ const singularTypeForRepo = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3740
+ const overlayAllowedForRepoDel = _ObjectStackProtocolImplementation.isOverlayAllowed(singularTypeForRepo);
3741
+ const runtimeCreateAllowedForRepoDel = _ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularTypeForRepo);
3742
+ const useRepoPath = overlayAllowedForRepoDel || runtimeCreateAllowedForRepoDel;
3187
3743
  if (useRepoPath) {
3188
3744
  const orgId = request.organizationId ?? null;
3189
3745
  const repo = this.getOverlayRepo(orgId);
@@ -3193,19 +3749,22 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3193
3749
  org: orgId ?? "env"
3194
3750
  };
3195
3751
  try {
3196
- const current = await repo.get(ref);
3752
+ const targetState = request.state === "draft" ? "draft" : "active";
3753
+ const current = await repo.get(ref, { state: targetState });
3197
3754
  if (!current) {
3198
3755
  return {
3199
3756
  success: true,
3200
3757
  reset: false,
3201
- message: `No customization overlay found for ${request.type}/${request.name} \u2014 already at artifact default.`
3758
+ 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
3759
  };
3203
3760
  }
3204
3761
  const parentVersion = request.parentVersion !== void 0 ? request.parentVersion ?? current.hash : current.hash;
3205
3762
  const result = await repo.delete(ref, {
3206
3763
  parentVersion,
3207
3764
  actor: request.actor ?? "system",
3208
- source: "protocol.deleteMetaItem"
3765
+ source: "protocol.deleteMetaItem",
3766
+ intent: this.isArtifactBacked(singularTypeForRepo, request.name) ? "override-artifact" : "runtime-only",
3767
+ state: targetState
3209
3768
  });
3210
3769
  if (this.environmentId === void 0) {
3211
3770
  try {
@@ -3224,7 +3783,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3224
3783
  success: true,
3225
3784
  reset: true,
3226
3785
  seq: result.seq,
3227
- message: `Customization overlay deleted \u2014 ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`
3786
+ 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
3787
  };
3229
3788
  } catch (err) {
3230
3789
  if (err instanceof ConflictError2) {
@@ -3302,7 +3861,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3302
3861
  for (const record of records) {
3303
3862
  try {
3304
3863
  const data = typeof record.metadata === "string" ? JSON.parse(record.metadata) : record.metadata;
3305
- const normalizedType = PLURAL_TO_SINGULAR2[record.type] ?? record.type;
3864
+ const normalizedType = PLURAL_TO_SINGULAR3[record.type] ?? record.type;
3306
3865
  if (normalizedType === "object") {
3307
3866
  this.engine.registry.registerObject(data, record.packageId || "sys_metadata");
3308
3867
  } else {
@@ -3336,7 +3895,7 @@ var _ObjectStackProtocolImplementation = class _ObjectStackProtocolImplementatio
3336
3895
  * — the engine never throws.
3337
3896
  */
3338
3897
  async findReferencesToMeta(request) {
3339
- const singularTarget = PLURAL_TO_SINGULAR2[request.type] ?? request.type;
3898
+ const singularTarget = PLURAL_TO_SINGULAR3[request.type] ?? request.type;
3340
3899
  const targetName = request.name;
3341
3900
  const matchers = REFERENCE_PATHS[singularTarget];
3342
3901
  if (!matchers || matchers.length === 0) {
@@ -3547,6 +4106,41 @@ _ObjectStackProtocolImplementation.OVERLAY_ALLOWED_TYPES = (() => {
3547
4106
  * {@link ObjectStackProtocolImplementation.resetEnvWritableCache}.
3548
4107
  */
3549
4108
  _ObjectStackProtocolImplementation._envWritableTypes = null;
4109
+ /**
4110
+ * Types that opt into runtime creation of brand-new items (ADR-0005
4111
+ * extension — two-tier model). A type may have
4112
+ * `allowOrgOverride: false` (cannot overlay artifact-shipped items)
4113
+ * yet still set `allowRuntimeCreate: true` (users can author new
4114
+ * items in `sys_metadata`). The two flags are orthogonal; see
4115
+ * {@link isArtifactBacked} for how the protocol decides which gate
4116
+ * applies to a given save/delete.
4117
+ */
4118
+ /**
4119
+ * Set of type names that have a static entry in
4120
+ * `DEFAULT_METADATA_TYPE_REGISTRY`. Anything outside this set is
4121
+ * runtime-registered (plugin-provided types like `theme`, `api`,
4122
+ * `connector`) — the listing endpoint at `getMetaTypes()` synthesises
4123
+ * those with `allowRuntimeCreate: true`, so this gate must agree.
4124
+ */
4125
+ _ObjectStackProtocolImplementation.STATIC_REGISTRY_TYPES = (() => {
4126
+ const out = /* @__PURE__ */ new Set();
4127
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY2) {
4128
+ out.add(entry.type);
4129
+ const plural = SINGULAR_TO_PLURAL2[entry.type];
4130
+ if (plural) out.add(plural);
4131
+ }
4132
+ return out;
4133
+ })();
4134
+ _ObjectStackProtocolImplementation.RUNTIME_CREATE_ALLOWED_TYPES = (() => {
4135
+ const out = /* @__PURE__ */ new Set();
4136
+ for (const entry of DEFAULT_METADATA_TYPE_REGISTRY2) {
4137
+ if (!entry.allowRuntimeCreate) continue;
4138
+ out.add(entry.type);
4139
+ const plural = SINGULAR_TO_PLURAL2[entry.type];
4140
+ if (plural) out.add(plural);
4141
+ }
4142
+ return out;
4143
+ })();
3550
4144
  var ObjectStackProtocolImplementation = _ObjectStackProtocolImplementation;
3551
4145
 
3552
4146
  // src/engine.ts