@powerhousedao/reactor 6.2.2-dev.37 → 6.2.2-dev.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { n as ReactorEventTypes, t as EventBusAggregateError } from "./types-DMKLa0Ok.js";
2
- import { DowngradeNotSupportedError, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createPresignedHeader, decide, defaultBaseState, deriveOperationId, garbageCollect, hashDocumentStateForScope, isDenied, isUndoRedo, sortOperations } from "@powerhousedao/shared/document-model";
2
+ import { AUTH_ACTION_TYPES, AUTH_DENIED_BY_GRANT_REASON, AUTH_NO_GRANT_REASON, AUTH_VERSION_UNSUPPORTED_REASON, DOCUMENT_DELETED_REASON, DowngradeNotSupportedError, appendWithoutApplying, applyAuthAction, applyDeleteDocumentAction, applyDeleteDocumentAction as applyDeleteDocumentAction$1, applyUpgradeDocumentAction, applyUpgradeDocumentAction as applyUpgradeDocumentAction$1, baseReducerVersion, createPresignedHeader, decide, defaultBaseState, deriveOperationId, evaluate, garbageCollect, hashDocumentStateForScope, isDenied, isUndoRedo, sortOperations } from "@powerhousedao/shared/document-model";
3
3
  import { v4 } from "uuid";
4
4
  import { Migrator, sql } from "kysely";
5
5
  //#region \0rolldown/runtime.js
@@ -93,6 +93,81 @@ var AuthorizationDeniedError = class AuthorizationDeniedError extends Error {
93
93
  }
94
94
  };
95
95
  /**
96
+ * An auth operation did not strictly exceed the newest timestamp in its stream.
97
+ *
98
+ * Terminal and asymmetric by design: no ordering rule can reconcile two replicas
99
+ * that each accepted an auth operation offline, because either order hands one
100
+ * authority the other never granted, so the replica ahead holds the arrival.
101
+ */
102
+ var AuthTimestampNotMonotonicError = class AuthTimestampNotMonotonicError extends Error {
103
+ documentId;
104
+ branch;
105
+ timestampUtcMs;
106
+ newestTimestampUtcMs;
107
+ constructor(documentId, branch, timestampUtcMs, newestTimestampUtcMs) {
108
+ super(`Auth timestamp not monotonic: ${timestampUtcMs} does not exceed ${newestTimestampUtcMs} in the auth stream of document ${documentId} on branch ${branch}`);
109
+ this.name = "AuthTimestampNotMonotonicError";
110
+ this.documentId = documentId;
111
+ this.branch = branch;
112
+ this.timestampUtcMs = timestampUtcMs;
113
+ this.newestTimestampUtcMs = newestTimestampUtcMs;
114
+ Error.captureStackTrace(this, AuthTimestampNotMonotonicError);
115
+ }
116
+ static isError(error) {
117
+ return Error.isError(error) && error.name === "AuthTimestampNotMonotonicError";
118
+ }
119
+ };
120
+ /**
121
+ * An operation or action carried a timestamp that is not an ISO-8601 UTC
122
+ * instant.
123
+ *
124
+ * Terminal rather than retryable: the value does not change between attempts,
125
+ * so a retry re-runs the whole job to fail identically. Quarantining, unlike a
126
+ * held auth operation — this is malformed data rather than two replicas
127
+ * disagreeing, and nothing further from that source should be trusted until it
128
+ * is looked at.
129
+ */
130
+ var InvalidOperationTimestampError = class InvalidOperationTimestampError extends Error {
131
+ documentId;
132
+ scope;
133
+ timestampUtcMs;
134
+ constructor(documentId, scope, timestampUtcMs, context) {
135
+ super(`Invalid timestamp "${timestampUtcMs}" on ${context} in scope "${scope}" of document ${documentId}`);
136
+ this.name = "InvalidOperationTimestampError";
137
+ this.documentId = documentId;
138
+ this.scope = scope;
139
+ this.timestampUtcMs = timestampUtcMs;
140
+ Error.captureStackTrace(this, InvalidOperationTimestampError);
141
+ }
142
+ static isError(error) {
143
+ return Error.isError(error) && error.name === "InvalidOperationTimestampError";
144
+ }
145
+ };
146
+ /**
147
+ * A load would move more operations than the bound allows, indicating a real
148
+ * divergence between local and incoming history. Counts only first-time moves,
149
+ * so a re-evaluation pass's re-appends do not make busy documents
150
+ * revocation-proof. Terminal: the condition is deterministic.
151
+ */
152
+ var ExcessiveReshuffleError = class ExcessiveReshuffleError extends Error {
153
+ documentId;
154
+ scope;
155
+ count;
156
+ threshold;
157
+ constructor(documentId, scope, count, threshold) {
158
+ super(`Excessive reshuffle detected: ${count} operations in scope "${scope}" of document ${documentId} exceeds the threshold of ${threshold}. This indicates a significant divergence between local and incoming operations.`);
159
+ this.name = "ExcessiveReshuffleError";
160
+ this.documentId = documentId;
161
+ this.scope = scope;
162
+ this.count = count;
163
+ this.threshold = threshold;
164
+ Error.captureStackTrace(this, ExcessiveReshuffleError);
165
+ }
166
+ static isError(error) {
167
+ return Error.isError(error) && error.name === "ExcessiveReshuffleError";
168
+ }
169
+ };
170
+ /**
96
171
  * Error thrown when an operation has an invalid signature.
97
172
  */
98
173
  var InvalidSignatureError = class InvalidSignatureError extends Error {
@@ -288,6 +363,34 @@ var CollectionMembershipCache = class CollectionMembershipCache {
288
363
  };
289
364
  //#endregion
290
365
  //#region src/executor/util.ts
366
+ /** Actions the reactor reduces itself, onto the document scope. */
367
+ const DOCUMENT_SCOPE_ACTIONS = new Set([
368
+ "CREATE_DOCUMENT",
369
+ "DELETE_DOCUMENT",
370
+ "UPGRADE_DOCUMENT",
371
+ "ADD_RELATIONSHIP",
372
+ "REMOVE_RELATIONSHIP",
373
+ "UPDATE_RELATIONSHIP"
374
+ ]);
375
+ /**
376
+ * `CREATE_DOCUMENT` is exempt by necessity: it runs before the document exists,
377
+ * so building a decision model would throw and defer the job forever.
378
+ */
379
+ const GATED_DOCUMENT_ACTIONS = new Set([...DOCUMENT_SCOPE_ACTIONS].filter((type) => type !== "CREATE_DOCUMENT"));
380
+ /**
381
+ * The document a document-scope action writes to, which is not always the job's
382
+ * own document: delete and upgrade name it in `input.documentId`, and the
383
+ * relationship actions in `input.sourceId`. `execute` only checks that a batch
384
+ * shares one scope, so a caller can submit an action whose target is a document
385
+ * other than the one the job is keyed by. The policy gate has to follow the
386
+ * action rather than the job, or it decides against a policy the caller may
387
+ * control instead of the one guarding the write.
388
+ */
389
+ function targetDocumentId(action, fallback) {
390
+ const input = action.input;
391
+ if (action.type === "ADD_RELATIONSHIP" || action.type === "REMOVE_RELATIONSHIP" || action.type === "UPDATE_RELATIONSHIP") return typeof input?.sourceId === "string" && input.sourceId.length > 0 ? input.sourceId : fallback;
392
+ return typeof input?.documentId === "string" && input.documentId.length > 0 ? input.documentId : fallback;
393
+ }
291
394
  /**
292
395
  * Creates a PHDocument from a CREATE_DOCUMENT action input.
293
396
  * Reconstructs the document header and initializes the base state.
@@ -426,6 +529,14 @@ function buildErrorResult(job, error, startTime) {
426
529
  };
427
530
  }
428
531
  /**
532
+ * The error a refusal surfaces as. Both classes are already terminal in the job
533
+ * result handler, so a refusal never burns a retry.
534
+ */
535
+ function refusalError(reason, documentId, deletedAtUtcIso, action) {
536
+ if (reason === DOCUMENT_DELETED_REASON) return new DocumentDeletedError(documentId, deletedAtUtcIso);
537
+ return new AuthorizationDeniedError(documentId, action.scope, action.type, action.context?.signer?.user.address);
538
+ }
539
+ /**
429
540
  * Whether this operation is part of the document's creation. The create and the
430
541
  * upgrade from version zero hold the first two indexes for the life of the
431
542
  * document, so a reshuffle has to leave them where they are.
@@ -984,20 +1095,6 @@ function keyframeRevision(keyframe, documentId, scope) {
984
1095
  if (typeof nextIndex !== "number") throw new Error(`Corrupt keyframe for document ${documentId} at revision ${keyframe.revision}: header carries no ${scope} revision`);
985
1096
  return nextIndex - 1;
986
1097
  }
987
- /**
988
- * Records a denied operation in the history without applying it. This is used
989
- * for auth-rejected operations, as these need to be recorded without being
990
- * applied.
991
- */
992
- function appendWithoutApplying(document, scope, operation) {
993
- return {
994
- ...document,
995
- operations: {
996
- ...document.operations,
997
- [scope]: [...document.operations[scope] ?? [], operation]
998
- }
999
- };
1000
- }
1001
1098
  function extractModuleVersion(doc) {
1002
1099
  const v = doc.state.document.version;
1003
1100
  return v === 0 ? void 0 : v;
@@ -1395,7 +1492,7 @@ var KyselyWriteCache = class KyselyWriteCache {
1395
1492
  for (const operation of result.results) {
1396
1493
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1397
1494
  const moduleVersion = this.resolveModuleVersionForOp(operation.index, operation.timestampUtcMs, scope, validatedUpgrades, extractModuleVersion(document));
1398
- if (isDenied(operation)) document = appendWithoutApplying(document, scope, operation);
1495
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
1399
1496
  else {
1400
1497
  const protocolVersion = baseReducerVersion(document.header);
1401
1498
  document = getModuleCached(moduleVersion).reducer(document, operation.action, void 0, {
@@ -1467,7 +1564,7 @@ var KyselyWriteCache = class KyselyWriteCache {
1467
1564
  for (const operation of pagedResults.results) {
1468
1565
  if (signal?.aborted) throw new Error("Operation aborted");
1469
1566
  if (targetRevision !== void 0 && operation.index > targetRevision) break;
1470
- if (isDenied(operation)) document = appendWithoutApplying(document, scope, operation);
1567
+ if (isDenied(operation)) document = appendWithoutApplying(document, operation, scope);
1471
1568
  else {
1472
1569
  const protocolVersion = baseReducerVersion(document.header);
1473
1570
  document = module.reducer(document, operation.action, void 0, {
@@ -1555,6 +1652,32 @@ var EventBus = class {
1555
1652
  }
1556
1653
  };
1557
1654
  //#endregion
1655
+ //#region src/core/feature-flags.ts
1656
+ /**
1657
+ * Every flag this reactor knows, with the flags it requires. A stage adds its
1658
+ * flag here when it ships, so asking an older reactor for a later stage's flag
1659
+ * is an unrecognized name rather than a flag that quietly does nothing.
1660
+ */
1661
+ const FLAG_PREREQUISITES = {
1662
+ documentDecisions: [],
1663
+ authEnforcement: ["documentDecisions"]
1664
+ };
1665
+ /**
1666
+ * Throws when the flags ask for enforcement the reactor cannot deliver. Either
1667
+ * failure would otherwise read as enforcement being on while the reactor
1668
+ * applies less than the caller asked for.
1669
+ */
1670
+ function validateFeatureFlags(flags, prerequisites) {
1671
+ const known = Object.keys(prerequisites);
1672
+ const unrecognized = Object.keys(flags).filter((name) => !known.includes(name));
1673
+ if (unrecognized.length > 0) throw new Error(`Unrecognized reactor feature flag: ${unrecognized.join(", ")}. This reactor knows: ${known.join(", ")}.`);
1674
+ for (const name of known) {
1675
+ if (flags[name] !== true) continue;
1676
+ const missing = prerequisites[name].filter((required) => flags[required] !== true);
1677
+ if (missing.length > 0) throw new Error(`Reactor feature flag ${name} requires ${missing.join(", ")}.`);
1678
+ }
1679
+ }
1680
+ //#endregion
1558
1681
  //#region src/executor/execution-scope.ts
1559
1682
  var DefaultExecutionScope = class {
1560
1683
  constructor(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache) {
@@ -1652,6 +1775,58 @@ function reshuffleByTimestamp(startIndex, opsA, opsB) {
1652
1775
  }));
1653
1776
  }
1654
1777
  //#endregion
1778
+ //#region src/decision/auth-decision-model.ts
1779
+ function refusalReason(refusal) {
1780
+ switch (refusal) {
1781
+ case "version-unsupported": return AUTH_VERSION_UNSUPPORTED_REASON;
1782
+ case "denied-by-grant": return AUTH_DENIED_BY_GRANT_REASON;
1783
+ case "no-applicable-grant": return AUTH_NO_GRANT_REASON;
1784
+ }
1785
+ }
1786
+ /** This decision model uses both the document and the auth streams. */
1787
+ function authDecisionModel(target) {
1788
+ return {
1789
+ projections: {
1790
+ document: {
1791
+ decidingActions: ["DELETE_DOCUMENT"],
1792
+ apply: (document, operation) => operation.action.type === "DELETE_DOCUMENT" ? applyDeleteDocumentAction({
1793
+ ...document,
1794
+ state: { ...document.state }
1795
+ }, operation.action) : document,
1796
+ query: {
1797
+ documentId: target.documentId,
1798
+ branch: target.branch,
1799
+ scope: "document"
1800
+ }
1801
+ },
1802
+ auth: {
1803
+ decidingActions: [...AUTH_ACTION_TYPES],
1804
+ apply: (document, operation) => applyAuthAction(document, operation.action),
1805
+ query: {
1806
+ documentId: target.documentId,
1807
+ branch: target.branch,
1808
+ scope: "auth"
1809
+ }
1810
+ }
1811
+ },
1812
+ evaluatesScope() {
1813
+ return true;
1814
+ },
1815
+ decide(model, subject, request) {
1816
+ if (request.verb === "execute" && model.document.isDeleted) return {
1817
+ decision: "deny",
1818
+ reason: DOCUMENT_DELETED_REASON
1819
+ };
1820
+ const evaluation = evaluate(model.auth, subject, request);
1821
+ if (evaluation.decision === "allow") return { decision: "allow" };
1822
+ return {
1823
+ decision: "deny",
1824
+ reason: refusalReason(evaluation.refusal)
1825
+ };
1826
+ }
1827
+ };
1828
+ }
1829
+ //#endregion
1655
1830
  //#region src/decision/build-decision-model.ts
1656
1831
  /**
1657
1832
  * Reads each projection's stream through the write cache, recording the
@@ -1721,9 +1896,10 @@ function observedRevision(document, scope) {
1721
1896
  */
1722
1897
  function staticReadSet(definition) {
1723
1898
  const streams = [];
1724
- for (const projection of Object.values(definition.projections)) {
1899
+ for (const [name, projection] of Object.entries(definition.projections)) {
1725
1900
  if (typeof projection.query === "function") continue;
1726
1901
  streams.push({
1902
+ name,
1727
1903
  query: projection.query,
1728
1904
  decidingActions: projection.decidingActions,
1729
1905
  apply: projection.apply
@@ -1733,8 +1909,6 @@ function staticReadSet(definition) {
1733
1909
  }
1734
1910
  //#endregion
1735
1911
  //#region src/decision/document-decision-model.ts
1736
- /** Why the model refused an operation. */
1737
- const DOCUMENT_DELETED_REASON = "document deleted";
1738
1912
  /**
1739
1913
  * The simplest decision model: one projection over the document scope, which
1740
1914
  * rejects on a deleted document.
@@ -1757,11 +1931,36 @@ function documentDecisionModel(target) {
1757
1931
  return true;
1758
1932
  },
1759
1933
  decide(model) {
1760
- return model.document.isDeleted ? "deny" : "allow";
1934
+ return model.document.isDeleted ? {
1935
+ decision: "deny",
1936
+ reason: DOCUMENT_DELETED_REASON
1937
+ } : { decision: "allow" };
1761
1938
  }
1762
1939
  };
1763
1940
  }
1764
1941
  //#endregion
1942
+ //#region src/decision/registered-model.ts
1943
+ /**
1944
+ * Builds the model at the stream heads and decides one request against it. The
1945
+ * append condition it returns is the read-set the store enforces at write time.
1946
+ */
1947
+ async function decideAtHead(model, cache, target, subject, request, signal) {
1948
+ const built = await buildDecisionModel(cache, model, target, signal);
1949
+ return {
1950
+ evaluation: model(target).decide(built.model, subject, request, { scopeState: void 0 }),
1951
+ appendCondition: built.appendCondition,
1952
+ documentVersion: built.model.document.version,
1953
+ deletedAtUtcIso: built.model.document.deletedAtUtcIso ?? null
1954
+ };
1955
+ }
1956
+ /**
1957
+ * The model this reactor enforces. With `authEnforcement` off the auth scope is
1958
+ * absent from every append condition and no load walks it.
1959
+ */
1960
+ function selectDecisionModel(flags) {
1961
+ return flags.authEnforcement ? authDecisionModel : documentDecisionModel;
1962
+ }
1963
+ //#endregion
1765
1964
  //#region src/decision/merged-order.ts
1766
1965
  /** Identifies a stream within a walk. */
1767
1966
  function streamKey(query) {
@@ -1769,15 +1968,17 @@ function streamKey(query) {
1769
1968
  }
1770
1969
  /**
1771
1970
  * Orders two operations from different streams by position. Timestamp decides;
1772
- * an equal timestamp falls to the action id and then the operation id, so that
1773
- * two replicas holding the same operations agree on the order whatever order
1774
- * they happen to store them in.
1971
+ * an equal timestamp puts an auth operation first, and otherwise falls to the
1972
+ * action id and then the operation id, so that two replicas holding the same
1973
+ * operations agree on the order whatever order they happen to store them in.
1775
1974
  */
1776
1975
  function comparePositions(a, b) {
1777
1976
  const aTime = Date.parse(a.operation.timestampUtcMs);
1778
1977
  const bTime = Date.parse(b.operation.timestampUtcMs);
1779
1978
  if (aTime !== bTime) return aTime - bTime;
1780
1979
  if (a.streamKey === b.streamKey) return a.operation.index - b.operation.index;
1980
+ const aAuth = a.scope === "auth";
1981
+ if (aAuth !== (b.scope === "auth")) return aAuth ? -1 : 1;
1781
1982
  const actionIds = (a.operation.action.id ?? "").localeCompare(b.operation.action.id ?? "");
1782
1983
  if (actionIds !== 0) return actionIds;
1783
1984
  return (a.operation.id ?? "").localeCompare(b.operation.id ?? "");
@@ -1791,6 +1992,7 @@ function mergeByPosition(streams) {
1791
1992
  const merged = [];
1792
1993
  for (const stream of streams) for (const operation of stream.operations) merged.push({
1793
1994
  streamKey: stream.streamKey,
1995
+ scope: stream.scope,
1794
1996
  operation
1795
1997
  });
1796
1998
  return merged.sort(comparePositions);
@@ -1809,15 +2011,17 @@ function retractionSkip(nextIndex, firstRetractedIndex) {
1809
2011
  * A single forward pass is only correct while a stream's effective operations
1810
2012
  * are ordered.
1811
2013
  */
1812
- function assertPositionOrder(streamKey, operations) {
2014
+ function assertPositionOrder(streamKey, scope, operations) {
1813
2015
  for (let i = 1; i < operations.length; i++) {
1814
2016
  const previous = operations[i - 1];
1815
2017
  const current = operations[i];
1816
2018
  if (comparePositions({
1817
2019
  streamKey,
2020
+ scope,
1818
2021
  operation: previous
1819
2022
  }, {
1820
2023
  streamKey,
2024
+ scope,
1821
2025
  operation: current
1822
2026
  }) > 0) throw new Error(`Stream ${streamKey} is out of position order: index ${previous.index} at ${previous.timestampUtcMs} precedes index ${current.index} at ${current.timestampUtcMs}`);
1823
2027
  }
@@ -1825,32 +2029,35 @@ function assertPositionOrder(streamKey, operations) {
1825
2029
  /**
1826
2030
  * Visits every operation in the read-set once, in the order their positions
1827
2031
  * fall, and hands back the state each stream held just before it. That state is
1828
- * what a decision at that operation reads: everything ahead of it has been
1829
- * applied and it has not.
2032
+ * what a decision at that operation reads.
1830
2033
  *
1831
2034
  * Skips are resolved first (i.e. this is performed on a garbage collected
1832
2035
  * stream), which means we can do a single forward pass.
1833
2036
  *
1834
- * A denied operation is visited but not applied.
2037
+ * An operation that contributes no state, whether denied or holding a reducer
2038
+ * error, is visited but not applied (this matches the write cache's rebuild).
2039
+ *
2040
+ * The consumer sends back whether it refused the operation it was handed: a
2041
+ * refusal this pass produced must suppress it the same way a stored one does.
1835
2042
  */
1836
2043
  function* walkByPosition(streams) {
1837
2044
  const merged = mergeByPosition(streams.map((stream) => {
1838
2045
  const operations = garbageCollect(sortOperations([...stream.operations]));
1839
- assertPositionOrder(stream.streamKey, operations);
2046
+ assertPositionOrder(stream.streamKey, stream.scope, operations);
1840
2047
  return {
1841
2048
  streamKey: stream.streamKey,
2049
+ scope: stream.scope,
1842
2050
  operations
1843
2051
  };
1844
2052
  }));
1845
2053
  const byKey = new Map(streams.map((stream) => [stream.streamKey, stream]));
1846
2054
  const states = new Map(streams.map((stream) => [stream.streamKey, stream.document]));
1847
2055
  for (const { streamKey, operation } of merged) {
1848
- yield {
2056
+ if ((yield {
1849
2057
  streamKey,
1850
2058
  operation,
1851
2059
  states: new Map(states)
1852
- };
1853
- if (isDenied(operation)) continue;
2060
+ }) || operation.error !== void 0 || isDenied(operation)) continue;
1854
2061
  const stream = byKey.get(streamKey);
1855
2062
  const before = states.get(streamKey);
1856
2063
  if (before === void 0 || stream === void 0) throw new Error(`No state for stream ${streamKey}`);
@@ -1858,70 +2065,99 @@ function* walkByPosition(streams) {
1858
2065
  }
1859
2066
  }
1860
2067
  //#endregion
1861
- //#region src/decision/deletion-evaluation.ts
1862
- const WRITTEN = "written";
1863
- /** Whether a read stream counts this action as one that changes an evaluation. */
1864
- function canRefuseOthers(operation, readSet) {
2068
+ //#region src/decision/evaluation.ts
2069
+ /** The stream key for evaluated operations whose scope no projection reads. */
2070
+ const EVALUATED_ONLY = "evaluated";
2071
+ /**
2072
+ * Whether any stream the model reads declares this operation's action type as
2073
+ * one that can change an evaluation.
2074
+ */
2075
+ function isDecidingAction(operation, readSet) {
1865
2076
  return readSet.some((stream) => stream.decidingActions.includes(operation.action.type));
1866
2077
  }
1867
- /** The deleted one of these, or the first if none of them is deleted. */
1868
- function firstDeleted(candidates) {
1869
- for (const candidate of candidates) if (candidate?.state.document.isDeleted) return candidate.state.document;
1870
- return candidates[0].state.document;
2078
+ /**
2079
+ * Who an operation acts as. A replayed operation is evaluated as its own signer,
2080
+ * so an address-scoped policy does not deny its own author's history.
2081
+ */
2082
+ function subjectOf(operation) {
2083
+ const signer = operation.action.context?.signer;
2084
+ return {
2085
+ address: signer?.user.address,
2086
+ key: signer?.app.key
2087
+ };
2088
+ }
2089
+ /**
2090
+ * The model as the walk reached this operation: each projection's value is its
2091
+ * own scope's state, taken from the stream that projection reads.
2092
+ */
2093
+ function modelAt(readSet, states) {
2094
+ const model = {};
2095
+ for (const stream of readSet) {
2096
+ const document = states.get(streamKey(stream.query));
2097
+ if (document === void 0) throw new Error(`No state walked for projection ${stream.name}`);
2098
+ model[stream.name] = document.state[stream.query.scope];
2099
+ }
2100
+ return model;
1871
2101
  }
1872
2102
  /**
1873
- * This function determines which operations will be refused because of
1874
- * deletes, returning reasons in a parallel array (undefined means the
1875
- * operation is not refused).
2103
+ * Evaluates each operation at its own position and returns the refusals in an
2104
+ * array parallel to the operations, where undefined means allowed.
1876
2105
  *
1877
- * A deletion already in the stream refuses the operations timestamped after it
1878
- * and leaves the earlier ones alone. A deletion among the ones passed in does
1879
- * the same to those after it.
2106
+ * A position is a timestamp, so an operation refused by a delete is one that
2107
+ * sorts after it, and the operations before it are left alone. That holds
2108
+ * whether the delete is already stored or is among the operations passed in.
1880
2109
  */
1881
- async function evaluateDeletionsByPosition(target, subject, stores, signal) {
2110
+ async function evaluateByPosition(model, target, subject, stores, signal) {
1882
2111
  const { scope, operations } = subject;
1883
2112
  const { writeCache, operationStore } = stores;
1884
- const definition = documentDecisionModel(target);
2113
+ const definition = model(target);
1885
2114
  const readSet = staticReadSet(definition);
1886
2115
  if (!definition.evaluatesScope(scope)) return operations.map(() => void 0);
1887
2116
  const evaluating = new Set(operations.map((operation) => operation.id));
1888
2117
  const readStreams = await Promise.all(readSet.map(async (stream) => ({
1889
2118
  stream,
1890
- operations: (await operationStore.getSince(stream.query.documentId, stream.query.scope, stream.query.branch, 0, { actionTypes: stream.decidingActions }, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id))
2119
+ operations: (await operationStore.getSince(stream.query.documentId, stream.query.scope, stream.query.branch, -1, { actionTypes: stream.decidingActions }, void 0, signal)).results.filter((operation) => !evaluating.has(operation.id))
1891
2120
  })));
1892
- const decidingWritten = operations.filter((operation) => canRefuseOthers(operation, readSet));
1893
- if (readStreams.every((read) => read.operations.length === 0) && decidingWritten.length === 0) return operations.map(() => void 0);
2121
+ const decidingOperations = operations.filter((operation) => isDecidingAction(operation, readSet));
2122
+ if (readStreams.every((read) => read.operations.length === 0) && decidingOperations.length === 0) return operations.map(() => void 0);
1894
2123
  if (readStreams.length === 0) throw new Error(`Decision model for ${target.documentId} reads no stream whose query is known before it is built`);
2124
+ const writtenProjection = readSet.find((stream) => stream.query.scope === scope);
1895
2125
  const walked = [];
1896
2126
  for (const read of readStreams) {
1897
- const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, 0, signal);
2127
+ const isWritten = read.stream === writtenProjection;
2128
+ const before = await writeCache.getState(read.stream.query.documentId, read.stream.query.scope, read.stream.query.branch, -1, signal);
1898
2129
  walked.push({
1899
2130
  streamKey: streamKey(read.stream.query),
2131
+ scope: read.stream.query.scope,
1900
2132
  document: before,
1901
- operations: read.operations,
2133
+ operations: isWritten ? [...read.operations, ...operations] : read.operations,
1902
2134
  apply: read.stream.apply
1903
2135
  });
1904
2136
  }
1905
- const writtenStreamIsRead = readSet.find((stream) => stream.query.scope === scope);
1906
- walked.push({
1907
- streamKey: WRITTEN,
2137
+ if (writtenProjection === void 0) walked.push({
2138
+ streamKey: EVALUATED_ONLY,
2139
+ scope,
1908
2140
  document: walked[0].document,
1909
2141
  operations,
1910
- apply: writtenStreamIsRead?.apply ?? ((document) => document)
2142
+ apply: (document) => document
1911
2143
  });
1912
2144
  const reasons = /* @__PURE__ */ new Map();
1913
- for (const step of walkByPosition(walked)) {
1914
- if (step.streamKey !== WRITTEN) continue;
1915
- const document = firstDeleted([...step.states.values()]);
1916
- const decision = definition.decide({ document }, {
1917
- address: void 0,
1918
- key: void 0
1919
- }, {
2145
+ const walk = walkByPosition(walked);
2146
+ let step = walk.next(false);
2147
+ while (!step.done) {
2148
+ const position = step.value;
2149
+ if (!evaluating.has(position.operation.id)) {
2150
+ step = walk.next(false);
2151
+ continue;
2152
+ }
2153
+ const evaluation = definition.decide(modelAt(readSet, position.states), subjectOf(position.operation), {
1920
2154
  verb: "execute",
1921
- scope,
1922
- operation: step.operation.action.type
2155
+ scope: position.operation.action.scope,
2156
+ operation: position.operation.action.type
1923
2157
  }, { scopeState: void 0 });
1924
- reasons.set(step.operation.id, decision === "deny" ? DOCUMENT_DELETED_REASON : void 0);
2158
+ const denied = evaluation.decision === "deny";
2159
+ reasons.set(position.operation.id, denied ? evaluation.reason : void 0);
2160
+ step = walk.next(denied);
1925
2161
  }
1926
2162
  return operations.map((operation) => reasons.get(operation.id));
1927
2163
  }
@@ -1972,19 +2208,22 @@ var DriveCollectionId = class DriveCollectionId {
1972
2208
  //#endregion
1973
2209
  //#region src/executor/document-action-handler.ts
1974
2210
  var DocumentActionHandler = class {
1975
- constructor(registry, logger, driveContainerTypes, featureFlags) {
2211
+ constructor(registry, logger, driveContainerTypes, featureFlags, decisionModel) {
1976
2212
  this.registry = registry;
1977
2213
  this.logger = logger;
1978
2214
  this.driveContainerTypes = driveContainerTypes;
1979
2215
  this.featureFlags = featureFlags;
2216
+ this.decisionModel = decisionModel;
1980
2217
  }
1981
2218
  /** Whether the write arrives with its evaluation already decided. */
1982
2219
  alreadyEvaluated(executing) {
1983
- return this.featureFlags.documentDecisions && executing.replayingAcceptedHistory;
2220
+ return this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
1984
2221
  }
1985
2222
  async execute(write, executing) {
1986
2223
  const { action } = write;
1987
2224
  if (write.deniedReason !== void 0) return this.writeDenied(write, executing);
2225
+ const refusal = await this.refuseIfPolicyDenies(write, executing);
2226
+ if (refusal) return refusal;
1988
2227
  switch (action.type) {
1989
2228
  case "CREATE_DOCUMENT": return this.executeCreate(write, executing);
1990
2229
  case "DELETE_DOCUMENT": return this.executeDelete(write, executing);
@@ -1995,6 +2234,34 @@ var DocumentActionHandler = class {
1995
2234
  default: return buildErrorResult(executing.job, /* @__PURE__ */ new Error(`Unknown document action type: ${action.type}`), executing.startTime);
1996
2235
  }
1997
2236
  }
2237
+ /**
2238
+ * Refuses a document-scope write the policy denies, or undefined to proceed.
2239
+ * Without this an `execute`-on-`document` grant is unenforceable.
2240
+ */
2241
+ async refuseIfPolicyDenies(write, executing) {
2242
+ const { action } = write;
2243
+ const { job, startTime, stores, signal } = executing;
2244
+ if (!this.featureFlags.documentDecisions || !this.featureFlags.authEnforcement || this.alreadyEvaluated(executing) || !GATED_DOCUMENT_ACTIONS.has(action.type)) return;
2245
+ const documentId = targetDocumentId(action, job.documentId);
2246
+ let admission;
2247
+ try {
2248
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, {
2249
+ documentId,
2250
+ branch: job.branch
2251
+ }, {
2252
+ address: action.context?.signer?.user.address,
2253
+ key: action.context?.signer?.app.key
2254
+ }, {
2255
+ verb: "execute",
2256
+ scope: action.scope,
2257
+ operation: action.type
2258
+ }, signal);
2259
+ } catch (error) {
2260
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2261
+ }
2262
+ if (admission.evaluation.decision === "allow") return;
2263
+ return buildErrorResult(job, refusalError(admission.evaluation.reason, documentId, admission.deletedAtUtcIso, action), startTime);
2264
+ }
1998
2265
  /** A refused operation holds a position in the stream but changes nothing. */
1999
2266
  async writeDenied(write, executing) {
2000
2267
  const { action, skip, sourceRemote, deniedReason } = write;
@@ -2005,13 +2272,20 @@ var DocumentActionHandler = class {
2005
2272
  } catch (error) {
2006
2273
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2007
2274
  }
2008
- let operation = createOperation(action, getNextIndexForScope(document, job.scope), skip, {
2275
+ const index = getNextIndexForScope(document, job.scope);
2276
+ let standing = document;
2277
+ if (skip > 0) try {
2278
+ standing = await stores.writeCache.getState(job.documentId, job.scope, job.branch, index - skip - 1, signal);
2279
+ } catch (error) {
2280
+ return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2281
+ }
2282
+ let operation = createOperation(action, index, skip, {
2009
2283
  documentId: job.documentId,
2010
2284
  scope: job.scope,
2011
2285
  branch: job.branch
2012
2286
  });
2013
2287
  operation.deniedReason = deniedReason;
2014
- operation.hash = hashDocumentStateForScope(document, job.scope);
2288
+ operation.hash = hashDocumentStateForScope(standing, job.scope);
2015
2289
  const writeResult = await this.writeOperationToStore({
2016
2290
  documentId: job.documentId,
2017
2291
  documentType: document.header.documentType,
@@ -2020,12 +2294,12 @@ var DocumentActionHandler = class {
2020
2294
  }, operation, executing);
2021
2295
  if (!Array.isArray(writeResult)) return writeResult;
2022
2296
  operation = writeResult[0];
2023
- updateDocumentRevision(document, job.scope, operation.index);
2024
- document.operations = {
2025
- ...document.operations,
2026
- [job.scope]: [...document.operations[job.scope] ?? [], operation]
2297
+ updateDocumentRevision(standing, job.scope, operation.index);
2298
+ standing.operations = {
2299
+ ...standing.operations,
2300
+ [job.scope]: [...standing.operations[job.scope] ?? [], operation]
2027
2301
  };
2028
- stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, document, SnapshotPosition.Head);
2302
+ stores.writeCache.putState(job.documentId, job.scope, job.branch, operation.index, standing, SnapshotPosition.Head);
2029
2303
  indexTxn.write([{
2030
2304
  ...operation,
2031
2305
  documentId: job.documentId,
@@ -2035,13 +2309,13 @@ var DocumentActionHandler = class {
2035
2309
  sourceRemote
2036
2310
  }]);
2037
2311
  stores.documentMetaCache.putDocumentMeta(job.documentId, job.branch, {
2038
- state: document.state.document,
2039
- documentType: document.header.documentType,
2312
+ state: standing.state.document,
2313
+ documentType: standing.header.documentType,
2040
2314
  documentScopeRevision: operation.index + 1
2041
2315
  });
2042
- return buildSuccessResult(job, operation, job.documentId, document.header.documentType, JSON.stringify({
2043
- header: document.header,
2044
- document: document.state.document
2316
+ return buildSuccessResult(job, operation, job.documentId, standing.header.documentType, JSON.stringify({
2317
+ header: standing.header,
2318
+ document: standing.state.document
2045
2319
  }), startTime);
2046
2320
  }
2047
2321
  async executeCreate(write, executing) {
@@ -2391,20 +2665,13 @@ function isValidISOTimestamp(value) {
2391
2665
  if (!ISO_TIMESTAMP_REGEX.test(value)) return false;
2392
2666
  return !isNaN(new Date(value).getTime());
2393
2667
  }
2394
- const documentScopeActions = [
2395
- "CREATE_DOCUMENT",
2396
- "DELETE_DOCUMENT",
2397
- "UPGRADE_DOCUMENT",
2398
- "ADD_RELATIONSHIP",
2399
- "REMOVE_RELATIONSHIP",
2400
- "UPDATE_RELATIONSHIP"
2401
- ];
2402
2668
  /**
2403
2669
  * Simple job executor that processes a job by applying actions through document model reducers.
2404
2670
  */
2405
2671
  var SimpleJobExecutor = class {
2406
2672
  config;
2407
2673
  featureFlags;
2674
+ decisionModel;
2408
2675
  signatureVerifierModule;
2409
2676
  documentActionHandler;
2410
2677
  executionScope;
@@ -2427,9 +2694,14 @@ var SimpleJobExecutor = class {
2427
2694
  retryMaxDelayMs: config.retryMaxDelayMs ?? 5e3,
2428
2695
  yieldDeadlineMs: config.yieldDeadlineMs ?? 50
2429
2696
  };
2430
- this.featureFlags = { documentDecisions: config.featureFlags?.documentDecisions ?? false };
2697
+ this.featureFlags = {
2698
+ documentDecisions: config.featureFlags?.documentDecisions ?? false,
2699
+ authEnforcement: config.featureFlags?.authEnforcement ?? false
2700
+ };
2701
+ validateFeatureFlags(this.featureFlags, FLAG_PREREQUISITES);
2702
+ this.decisionModel = selectDecisionModel(this.featureFlags);
2431
2703
  this.signatureVerifierModule = new SignatureVerifier(signatureVerifier);
2432
- this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags);
2704
+ this.documentActionHandler = new DocumentActionHandler(registry, logger, driveContainerTypes, this.featureFlags, this.decisionModel);
2433
2705
  this.executionScope = executionScope ?? new DefaultExecutionScope(operationStore, operationIndex, writeCache, documentMetaCache, collectionMembershipCache);
2434
2706
  }
2435
2707
  /**
@@ -2451,7 +2723,8 @@ var SimpleJobExecutor = class {
2451
2723
  indexTxn,
2452
2724
  stores,
2453
2725
  signal,
2454
- replayingAcceptedHistory: true
2726
+ replayingAcceptedHistory: true,
2727
+ evaluatedByPosition: false
2455
2728
  });
2456
2729
  if (loadResult.success && loadResult.operationsWithContext) {
2457
2730
  for (const owc of loadResult.operationsWithContext) touchedCacheEntries.push({
@@ -2471,20 +2744,18 @@ var SimpleJobExecutor = class {
2471
2744
  }
2472
2745
  return loadResult;
2473
2746
  }
2747
+ const positioned = await this.positionByTimestamp(job, stores, signal);
2748
+ if (positioned.error) return buildErrorResult(job, positioned.error, startTime);
2474
2749
  const executing = {
2475
2750
  job,
2476
2751
  startTime,
2477
2752
  indexTxn,
2478
2753
  stores,
2479
2754
  signal,
2480
- replayingAcceptedHistory: false
2755
+ replayingAcceptedHistory: false,
2756
+ evaluatedByPosition: positioned.evaluatedByPosition
2481
2757
  };
2482
- const positioned = await this.positionByTimestamp(job, stores, signal);
2483
- const actionResult = await this.processActions(positioned.actions.map((action, i) => ({
2484
- action,
2485
- skip: positioned.skipValues?.[i] ?? 0,
2486
- sourceRemote: ""
2487
- })), executing);
2758
+ const actionResult = await this.processActions(positioned.writes, executing);
2488
2759
  if (!actionResult.success) return {
2489
2760
  job,
2490
2761
  success: false,
@@ -2560,11 +2831,11 @@ var SimpleJobExecutor = class {
2560
2831
  success: false,
2561
2832
  generatedOperations,
2562
2833
  operationsWithContext,
2563
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${action.timestampUtcMs}" on action ${action.type} (id: ${action.id})`)
2834
+ error: new InvalidOperationTimestampError(job.documentId, action.scope, action.timestampUtcMs, `action ${action.type} (id: ${action.id})`)
2564
2835
  };
2565
2836
  let lastYield = performance.now();
2566
2837
  for (const write of writes) {
2567
- const result = documentScopeActions.includes(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
2838
+ const result = DOCUMENT_SCOPE_ACTIONS.has(write.action.type) ? await this.documentActionHandler.execute(write, executing) : await this.executeRegularAction(write, executing);
2568
2839
  const error = this.accumulateResultOrReturnError(result, generatedOperations, operationsWithContext);
2569
2840
  if (error !== null) return {
2570
2841
  success: false,
@@ -2594,29 +2865,28 @@ var SimpleJobExecutor = class {
2594
2865
  const { job, startTime, indexTxn, stores, signal } = executing;
2595
2866
  let appendCondition;
2596
2867
  let documentVersion;
2597
- const alreadyEvaluated = this.featureFlags.documentDecisions && executing.replayingAcceptedHistory;
2868
+ const alreadyEvaluated = this.featureFlags.documentDecisions && (executing.replayingAcceptedHistory || executing.evaluatedByPosition);
2598
2869
  if (this.featureFlags.documentDecisions && !alreadyEvaluated) {
2599
2870
  const target = {
2600
2871
  documentId: job.documentId,
2601
2872
  branch: job.branch
2602
2873
  };
2603
- const definition = documentDecisionModel(target);
2604
- let built;
2874
+ let admission;
2605
2875
  try {
2606
- built = await buildDecisionModel(stores.writeCache, () => definition, target, signal);
2876
+ admission = await decideAtHead(this.decisionModel, stores.writeCache, target, {
2877
+ address: action.context?.signer?.user.address,
2878
+ key: action.context?.signer?.app.key
2879
+ }, {
2880
+ verb: "execute",
2881
+ scope: action.scope,
2882
+ operation: action.type
2883
+ }, signal);
2607
2884
  } catch (error) {
2608
2885
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2609
2886
  }
2610
- if (definition.decide(built.model, {
2611
- address: action.context?.signer?.user.address,
2612
- key: action.context?.signer?.app.key
2613
- }, {
2614
- verb: "execute",
2615
- scope: action.scope,
2616
- operation: action.type
2617
- }, { scopeState: void 0 }) === "deny") return buildErrorResult(job, new DocumentDeletedError(job.documentId, built.model.document.deletedAtUtcIso), startTime);
2618
- appendCondition = built.appendCondition;
2619
- documentVersion = built.model.document.version;
2887
+ if (admission.evaluation.decision === "deny") return buildErrorResult(job, refusalError(admission.evaluation.reason, job.documentId, admission.deletedAtUtcIso, action), startTime);
2888
+ appendCondition = admission.appendCondition;
2889
+ documentVersion = admission.documentVersion;
2620
2890
  } else if (alreadyEvaluated) documentVersion = (await stores.writeCache.getState(job.documentId, "document", job.branch, void 0, signal)).state.document.version;
2621
2891
  else {
2622
2892
  let docMeta;
@@ -2635,7 +2905,7 @@ var SimpleJobExecutor = class {
2635
2905
  } catch (error) {
2636
2906
  return buildErrorResult(job, error instanceof Error ? error : new Error(String(error)), startTime);
2637
2907
  }
2638
- if (!executing.replayingAcceptedHistory) {
2908
+ if (!this.featureFlags.authEnforcement && !executing.replayingAcceptedHistory) {
2639
2909
  const subject = {
2640
2910
  address: write.action.context?.signer?.user.address,
2641
2911
  key: write.action.context?.signer?.app.key
@@ -2753,19 +3023,51 @@ var SimpleJobExecutor = class {
2753
3023
  };
2754
3024
  }
2755
3025
  /**
2756
- * Orders a write by timestamp. The caller supplies the timestamp, so a write
2757
- * can belong before operations already stored, and appending it at the tail
2758
- * would leave the scope out of order. The operations it belongs before are
2759
- * re-appended alongside it, the way a load reshuffles.
3026
+ * Orders a write by timestamp and decides it where it lands. The caller
3027
+ * supplies the timestamp, so a write can belong before operations already
3028
+ * stored; those are re-appended alongside it, the way a load reshuffles.
3029
+ *
3030
+ * Deciding a backdated write at the stream heads instead of at its position
3031
+ * would overwrite the verdict every other replica computes for it.
2760
3032
  */
2761
3033
  async positionByTimestamp(job, stores, signal) {
2762
- if (!this.featureFlags.documentDecisions || job.actions.length === 0) return { actions: job.actions };
3034
+ const plain = () => ({
3035
+ writes: job.actions.map((action) => ({
3036
+ action,
3037
+ skip: 0,
3038
+ sourceRemote: ""
3039
+ })),
3040
+ evaluatedByPosition: false
3041
+ });
3042
+ if (!this.featureFlags.documentDecisions || job.actions.length === 0) return plain();
2763
3043
  let earliest = job.actions[0].timestampUtcMs;
2764
- for (const action of job.actions) if (action.timestampUtcMs < earliest) earliest = action.timestampUtcMs;
3044
+ let earliestAt = Date.parse(earliest);
3045
+ for (const action of job.actions) {
3046
+ const at = Date.parse(action.timestampUtcMs);
3047
+ if (at < earliestAt) {
3048
+ earliest = action.timestampUtcMs;
3049
+ earliestAt = at;
3050
+ }
3051
+ }
2765
3052
  const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
2766
- if (earliest >= revisions.latestTimestamp) return { actions: job.actions };
3053
+ const backdated = earliestAt < Date.parse(revisions.latestTimestamp);
3054
+ if (this.featureFlags.authEnforcement && job.scope === "auth") {
3055
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3056
+ const violation = this.firstNonMonotonicTimestamp(job.actions, newest, job.documentId, job.branch);
3057
+ if (violation) return {
3058
+ writes: [],
3059
+ evaluatedByPosition: false,
3060
+ error: violation
3061
+ };
3062
+ if (!backdated) return plain();
3063
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3064
+ }
3065
+ if (!backdated) return plain();
2767
3066
  const conflicting = (await stores.operationStore.getConflicting(job.documentId, job.scope, job.branch, earliest, void 0, signal)).results.filter((operation) => !isGenesisOperation(operation));
2768
- if (conflicting.length === 0) return { actions: job.actions };
3067
+ if (conflicting.length === 0) {
3068
+ if (!this.featureFlags.authEnforcement) return plain();
3069
+ return this.evaluatePositioned(job, stores, this.appendedOperations(job, revisions.revision[job.scope] ?? 0), signal);
3070
+ }
2769
3071
  const nextIndex = revisions.revision[job.scope] ?? 0;
2770
3072
  let firstConflicting = conflicting[0].index;
2771
3073
  for (const operation of conflicting) if (operation.index < firstConflicting) firstConflicting = operation.index;
@@ -2782,12 +3084,105 @@ var SimpleJobExecutor = class {
2782
3084
  skip: retractionSkip(nextIndex, firstConflicting)
2783
3085
  }, conflicting, incoming);
2784
3086
  stores.writeCache.invalidate(job.documentId, job.scope, job.branch);
3087
+ if (!this.featureFlags.authEnforcement) return {
3088
+ writes: merged.map((operation) => ({
3089
+ action: operation.action,
3090
+ skip: operation.skip,
3091
+ sourceRemote: ""
3092
+ })),
3093
+ evaluatedByPosition: false
3094
+ };
3095
+ return this.evaluatePositioned(job, stores, merged, signal);
3096
+ }
3097
+ /**
3098
+ * Decides each operation where it lands and carries the verdict on it. A
3099
+ * refused submitted action is reported to the caller and nothing is stored; a
3100
+ * refused operation the reshuffle merely moved keeps its verdict, because it
3101
+ * already holds a position.
3102
+ *
3103
+ * The operations carry the indexes and skips they will be stored at, because
3104
+ * the walk resolves skips before it orders them.
3105
+ */
3106
+ async evaluatePositioned(job, stores, operations, signal) {
3107
+ const reasons = await evaluateByPosition(this.decisionModel, {
3108
+ documentId: job.documentId,
3109
+ branch: job.branch
3110
+ }, {
3111
+ scope: job.scope,
3112
+ operations
3113
+ }, stores, signal);
3114
+ const submitted = new Set(job.actions.map((action) => action.id));
3115
+ for (let i = 0; i < operations.length; i++) {
3116
+ const reason = reasons[i];
3117
+ if (reason !== void 0 && submitted.has(operations[i].action.id)) return {
3118
+ writes: [],
3119
+ evaluatedByPosition: false,
3120
+ error: refusalError(reason, job.documentId, null, operations[i].action)
3121
+ };
3122
+ }
2785
3123
  return {
2786
- actions: merged.map((operation) => operation.action),
2787
- skipValues: merged.map((operation) => operation.skip)
3124
+ writes: operations.map((operation, i) => ({
3125
+ action: operation.action,
3126
+ skip: operation.skip,
3127
+ sourceRemote: "",
3128
+ deniedReason: reasons[i]
3129
+ })),
3130
+ evaluatedByPosition: true
2788
3131
  };
2789
3132
  }
2790
3133
  /**
3134
+ * The scopes a re-evaluation pass visits, in a fixed order.
3135
+ *
3136
+ * The revisions map comes from a query with no ORDER BY, and the order is
3137
+ * load-bearing: each scope's pass re-reads the auth stream, and the walk skips
3138
+ * an operation by its stored denial, so a denial this pass just wrote is
3139
+ * visible to a later-visited scope and invisible to an earlier one. The model's
3140
+ * own projection order leads, then the rest sorted, so the pass is reproducible
3141
+ * across replicas and across runs.
3142
+ */
3143
+ evaluationOrder(target, revision) {
3144
+ const definition = this.decisionModel(target);
3145
+ const evaluated = Object.keys(revision).filter((scope) => definition.evaluatesScope(scope));
3146
+ const leading = [];
3147
+ for (const stream of staticReadSet(definition)) {
3148
+ const scope = stream.query.scope;
3149
+ if (evaluated.includes(scope) && !leading.includes(scope)) leading.push(scope);
3150
+ }
3151
+ const rest = evaluated.filter((scope) => !leading.includes(scope)).sort((a, b) => a.localeCompare(b));
3152
+ return [...leading, ...rest];
3153
+ }
3154
+ /**
3155
+ * The first timestamp in the batch that does not strictly exceed everything
3156
+ * ahead of it, or undefined when the whole batch is monotonic.
3157
+ *
3158
+ * The bound is carried forward rather than compared against one stored maximum,
3159
+ * because a single execute can carry several auth actions stamped in the same
3160
+ * millisecond. Letting a tie through would store a stream the position walk
3161
+ * then refuses to read, with no repair path.
3162
+ */
3163
+ firstNonMonotonicTimestamp(entries, newest, documentId, branch) {
3164
+ let boundIso = newest;
3165
+ let bound = newest === void 0 ? Number.NEGATIVE_INFINITY : Date.parse(newest);
3166
+ for (const entry of entries) {
3167
+ if (!isValidISOTimestamp(entry.timestampUtcMs)) return new InvalidOperationTimestampError(documentId, "auth", entry.timestampUtcMs, "auth operation");
3168
+ const at = Date.parse(entry.timestampUtcMs);
3169
+ if (boundIso !== void 0 && at <= bound) return new AuthTimestampNotMonotonicError(documentId, branch, entry.timestampUtcMs, boundIso);
3170
+ bound = at;
3171
+ boundIso = entry.timestampUtcMs;
3172
+ }
3173
+ }
3174
+ /** The operations a batch of submitted actions appends at the scope's tail. */
3175
+ appendedOperations(job, nextIndex) {
3176
+ return job.actions.map((action, i) => ({
3177
+ id: action.id,
3178
+ index: nextIndex + i,
3179
+ skip: 0,
3180
+ hash: "",
3181
+ timestampUtcMs: action.timestampUtcMs,
3182
+ action
3183
+ }));
3184
+ }
3185
+ /**
2791
3186
  * Re-evaluates the document when a write meets both criteria: it was written
2792
3187
  * to a stream the model reads, and it is timestamped before an operation
2793
3188
  * already stored. The caller supplies the timestamp and the reactor does not replace
@@ -2797,10 +3192,11 @@ var SimpleJobExecutor = class {
2797
3192
  async reevaluateIfCriteriaMet(criteria, executing) {
2798
3193
  if (!this.featureFlags.documentDecisions) return;
2799
3194
  const { job, stores, signal } = executing;
2800
- if (!staticReadSet(documentDecisionModel({
3195
+ const target = {
2801
3196
  documentId: job.documentId,
2802
3197
  branch: job.branch
2803
- })).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
3198
+ };
3199
+ if (!staticReadSet(this.decisionModel(target)).some((stream) => stream.query.documentId === job.documentId && stream.query.scope === criteria.scope && stream.query.branch === job.branch)) return;
2804
3200
  const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
2805
3201
  const latest = Date.parse(revisions.latestTimestamp);
2806
3202
  if (!criteria.operations.some((operation) => Date.parse(operation.timestampUtcMs) < latest)) return;
@@ -2813,20 +3209,16 @@ var SimpleJobExecutor = class {
2813
3209
  */
2814
3210
  async reevaluateDocument(executing) {
2815
3211
  const { job, stores, signal } = executing;
2816
- const definition = documentDecisionModel({
3212
+ const target = {
2817
3213
  documentId: job.documentId,
2818
3214
  branch: job.branch
2819
- });
3215
+ };
2820
3216
  const revisions = await stores.operationStore.getRevisions(job.documentId, job.branch, signal);
2821
- for (const scope of Object.keys(revisions.revision)) {
2822
- if (!definition.evaluatesScope(scope)) continue;
3217
+ for (const scope of this.evaluationOrder(target, revisions.revision)) {
2823
3218
  const stored = (await stores.operationStore.getSince(job.documentId, scope, job.branch, -1, void 0, void 0, signal)).results;
2824
3219
  const effective = garbageCollect(sortOperations([...stored]));
2825
3220
  if (effective.length === 0) continue;
2826
- const reevaluated = await evaluateDeletionsByPosition({
2827
- documentId: job.documentId,
2828
- branch: job.branch
2829
- }, {
3221
+ const reevaluated = await evaluateByPosition(this.decisionModel, target, {
2830
3222
  scope,
2831
3223
  operations: effective
2832
3224
  }, stores, signal);
@@ -2846,7 +3238,8 @@ var SimpleJobExecutor = class {
2846
3238
  ...job,
2847
3239
  scope
2848
3240
  },
2849
- replayingAcceptedHistory: true
3241
+ replayingAcceptedHistory: true,
3242
+ evaluatedByPosition: true
2850
3243
  });
2851
3244
  if (!result.success) return result.error ?? /* @__PURE__ */ new Error(`Re-evaluation of ${job.documentId} ${scope} failed`);
2852
3245
  }
@@ -2860,6 +3253,7 @@ var SimpleJobExecutor = class {
2860
3253
  } catch {}
2861
3254
  if (docMeta?.state.isDeleted && !this.featureFlags.documentDecisions) return buildErrorResult(job, new DocumentDeletedError(job.documentId, docMeta.state.deletedAtUtcIso), startTime);
2862
3255
  const scope = job.scope;
3256
+ const monotonicAuthStream = this.featureFlags.authEnforcement && scope === "auth";
2863
3257
  let latestRevision;
2864
3258
  try {
2865
3259
  latestRevision = (await stores.operationStore.getRevisions(job.documentId, job.branch, signal)).revision[scope] ?? 0;
@@ -2869,7 +3263,7 @@ var SimpleJobExecutor = class {
2869
3263
  for (const operation of job.operations) if (operation.timestampUtcMs && !isValidISOTimestamp(operation.timestampUtcMs)) return {
2870
3264
  job,
2871
3265
  success: false,
2872
- error: /* @__PURE__ */ new Error(`Invalid timestamp "${operation.timestampUtcMs}" on operation (index: ${operation.index})`),
3266
+ error: new InvalidOperationTimestampError(job.documentId, scope, operation.timestampUtcMs, `operation (index: ${operation.index})`),
2873
3267
  duration: Date.now() - startTime
2874
3268
  };
2875
3269
  let minIncomingIndex = Number.POSITIVE_INFINITY;
@@ -2877,7 +3271,7 @@ var SimpleJobExecutor = class {
2877
3271
  for (const operation of job.operations) {
2878
3272
  minIncomingIndex = Math.min(minIncomingIndex, operation.index);
2879
3273
  const ts = operation.timestampUtcMs || "";
2880
- if (ts < minIncomingTimestamp) minIncomingTimestamp = ts;
3274
+ if (Date.parse(ts) < Date.parse(minIncomingTimestamp)) minIncomingTimestamp = ts;
2881
3275
  }
2882
3276
  let conflictingOps;
2883
3277
  try {
@@ -2902,11 +3296,14 @@ var SimpleJobExecutor = class {
2902
3296
  }
2903
3297
  return true;
2904
3298
  });
2905
- const existingOpsToReshuffle = nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
2906
- if (existingOpsToReshuffle.length > this.config.maxSkipThreshold) return {
3299
+ const existingOpsToReshuffle = monotonicAuthStream ? [] : nonSupersededOps.filter((operation) => !isGenesisOperation(operation));
3300
+ const actionIdCounts = /* @__PURE__ */ new Map();
3301
+ for (const operation of allOpsFromMinConflictingIndex) actionIdCounts.set(operation.action.id, (actionIdCounts.get(operation.action.id) ?? 0) + 1);
3302
+ const reshuffleCost = existingOpsToReshuffle.filter((operation) => (actionIdCounts.get(operation.action.id) ?? 0) < 2).length;
3303
+ if (reshuffleCost > this.config.maxSkipThreshold) return {
2907
3304
  job,
2908
3305
  success: false,
2909
- error: /* @__PURE__ */ new Error(`Excessive reshuffle detected: existing op count of ${existingOpsToReshuffle.length} exceeds threshold of ${this.config.maxSkipThreshold}. This indicates a significant divergence between local and incoming operations.`),
3306
+ error: new ExcessiveReshuffleError(job.documentId, scope, reshuffleCost, this.config.maxSkipThreshold),
2910
3307
  duration: Date.now() - startTime
2911
3308
  };
2912
3309
  let skipCount = existingOpsToReshuffle.length;
@@ -2934,6 +3331,16 @@ var SimpleJobExecutor = class {
2934
3331
  operationsWithContext: [],
2935
3332
  duration: Date.now() - startTime
2936
3333
  };
3334
+ if (monotonicAuthStream) {
3335
+ const newest = await stores.operationStore.getStreamLatestTimestamp(job.documentId, "auth", job.branch, signal);
3336
+ const violation = this.firstNonMonotonicTimestamp([...incomingOpsToApply].sort((a, b) => a.index - b.index), newest, job.documentId, job.branch);
3337
+ if (violation) return {
3338
+ job,
3339
+ success: false,
3340
+ error: violation,
3341
+ duration: Date.now() - startTime
3342
+ };
3343
+ }
2937
3344
  const reshuffledOperations = existingOpsToReshuffle.length === 0 && skipCount === 0 ? incomingOpsToApply.slice().sort((a, b) => a.index - b.index).map((operation, i) => ({
2938
3345
  ...operation,
2939
3346
  index: latestRevision + i
@@ -2947,7 +3354,7 @@ var SimpleJobExecutor = class {
2947
3354
  for (const operation of reshuffledOperations) if (operation.action.type === "NOOP") operation.skip = 1;
2948
3355
  let deniedReasons;
2949
3356
  if (this.featureFlags.documentDecisions) try {
2950
- deniedReasons = await evaluateDeletionsByPosition({
3357
+ deniedReasons = await evaluateByPosition(this.decisionModel, {
2951
3358
  documentId: job.documentId,
2952
3359
  branch: job.branch
2953
3360
  }, {
@@ -3500,6 +3907,10 @@ var KyselyOperationStore = class KyselyOperationStore {
3500
3907
  latestTimestamp: latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : (/* @__PURE__ */ new Date(0)).toISOString()
3501
3908
  };
3502
3909
  }
3910
+ async getStreamLatestTimestamp(documentId, scope, branch, signal) {
3911
+ const latest = await this.queryExecutor.selectFrom("Operation").select((eb) => eb.fn.max("timestampUtcMs").as("latestTimestamp")).where("documentId", "=", documentId).where("scope", "=", scope).where("branch", "=", branch).executeTakeFirst();
3912
+ return latest?.latestTimestamp ? new Date(latest.latestTimestamp).toISOString() : void 0;
3913
+ }
3503
3914
  rowToOperation(row) {
3504
3915
  return {
3505
3916
  index: row.index,
@@ -3593,8 +4004,8 @@ function createForwardingPoolInstrumentation(name) {
3593
4004
  }
3594
4005
  //#endregion
3595
4006
  //#region src/storage/migrations/001_create_operation_table.ts
3596
- var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
3597
- async function up$14(db) {
4007
+ var _001_create_operation_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$15 });
4008
+ async function up$15(db) {
3598
4009
  await db.schema.createTable("Operation").addColumn("id", "serial", (col) => col.primaryKey()).addColumn("jobId", "text", (col) => col.notNull()).addColumn("opId", "text", (col) => col.notNull()).addColumn("prevOpId", "text", (col) => col.notNull()).addColumn("writeTimestampUtcMs", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("timestampUtcMs", "timestamptz", (col) => col.notNull()).addColumn("index", "integer", (col) => col.notNull()).addColumn("action", "jsonb", (col) => col.notNull()).addColumn("skip", "integer", (col) => col.notNull()).addColumn("error", "text").addColumn("hash", "text", (col) => col.notNull()).addUniqueConstraint("unique_revision", [
3599
4010
  "documentId",
3600
4011
  "scope",
@@ -3619,8 +4030,8 @@ async function up$14(db) {
3619
4030
  }
3620
4031
  //#endregion
3621
4032
  //#region src/storage/migrations/002_create_keyframe_table.ts
3622
- var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
3623
- async function up$13(db) {
4033
+ var _002_create_keyframe_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$14 });
4034
+ async function up$14(db) {
3624
4035
  await db.schema.createTable("Keyframe").addColumn("id", "serial", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("revision", "integer", (col) => col.notNull()).addColumn("document", "jsonb", (col) => col.notNull()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_keyframe", [
3625
4036
  "documentId",
3626
4037
  "scope",
@@ -3636,14 +4047,14 @@ async function up$13(db) {
3636
4047
  }
3637
4048
  //#endregion
3638
4049
  //#region src/storage/migrations/003_create_document_table.ts
3639
- var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
3640
- async function up$12(db) {
4050
+ var _003_create_document_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$13 });
4051
+ async function up$13(db) {
3641
4052
  await db.schema.createTable("Document").addColumn("id", "text", (col) => col.primaryKey()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
3642
4053
  }
3643
4054
  //#endregion
3644
4055
  //#region src/storage/migrations/004_create_document_relationship_table.ts
3645
- var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
3646
- async function up$11(db) {
4056
+ var _004_create_document_relationship_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$12 });
4057
+ async function up$12(db) {
3647
4058
  await db.schema.createTable("DocumentRelationship").addColumn("id", "text", (col) => col.primaryKey()).addColumn("sourceId", "text", (col) => col.notNull().references("Document.id").onDelete("cascade")).addColumn("targetId", "text", (col) => col.notNull().references("Document.id").onDelete("cascade")).addColumn("relationshipType", "text", (col) => col.notNull()).addColumn("metadata", "jsonb").addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_source_target_type", [
3648
4059
  "sourceId",
3649
4060
  "targetId",
@@ -3655,14 +4066,14 @@ async function up$11(db) {
3655
4066
  }
3656
4067
  //#endregion
3657
4068
  //#region src/storage/migrations/005_create_indexer_state_table.ts
3658
- var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
3659
- async function up$10(db) {
4069
+ var _005_create_indexer_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$11 });
4070
+ async function up$11(db) {
3660
4071
  await db.schema.createTable("IndexerState").addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()).addColumn("lastOperationId", "integer", (col) => col.notNull()).addColumn("lastOperationTimestamp", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
3661
4072
  }
3662
4073
  //#endregion
3663
4074
  //#region src/storage/migrations/006_create_document_snapshot_table.ts
3664
- var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
3665
- async function up$9(db) {
4075
+ var _006_create_document_snapshot_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$10 });
4076
+ async function up$10(db) {
3666
4077
  await db.schema.createTable("DocumentSnapshot").addColumn("id", "text", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("slug", "text").addColumn("name", "text").addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("content", "jsonb", (col) => col.notNull()).addColumn("documentType", "text", (col) => col.notNull()).addColumn("lastOperationIndex", "integer", (col) => col.notNull()).addColumn("lastOperationHash", "text", (col) => col.notNull()).addColumn("lastUpdatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("snapshotVersion", "integer", (col) => col.notNull().defaultTo(1)).addColumn("identifiers", "jsonb").addColumn("metadata", "jsonb").addColumn("isDeleted", "boolean", (col) => col.notNull().defaultTo(false)).addColumn("deletedAt", "timestamptz").addUniqueConstraint("unique_doc_scope_branch", [
3667
4078
  "documentId",
3668
4079
  "scope",
@@ -3683,8 +4094,8 @@ async function up$9(db) {
3683
4094
  }
3684
4095
  //#endregion
3685
4096
  //#region src/storage/migrations/007_create_slug_mapping_table.ts
3686
- var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
3687
- async function up$8(db) {
4097
+ var _007_create_slug_mapping_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$9 });
4098
+ async function up$9(db) {
3688
4099
  await db.schema.createTable("SlugMapping").addColumn("slug", "text", (col) => col.primaryKey()).addColumn("documentId", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addUniqueConstraint("unique_docid_scope_branch", [
3689
4100
  "documentId",
3690
4101
  "scope",
@@ -3694,14 +4105,14 @@ async function up$8(db) {
3694
4105
  }
3695
4106
  //#endregion
3696
4107
  //#region src/storage/migrations/008_create_view_state_table.ts
3697
- var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
3698
- async function up$7(db) {
4108
+ var _008_create_view_state_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$8 });
4109
+ async function up$8(db) {
3699
4110
  await db.schema.createTable("ViewState").addColumn("readModelId", "text", (col) => col.primaryKey()).addColumn("lastOrdinal", "integer", (col) => col.notNull().defaultTo(0)).addColumn("lastOperationTimestamp", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
3700
4111
  }
3701
4112
  //#endregion
3702
4113
  //#region src/storage/migrations/009_create_operation_index_tables.ts
3703
- var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
3704
- async function up$6(db) {
4114
+ var _009_create_operation_index_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$7 });
4115
+ async function up$7(db) {
3705
4116
  await db.schema.createTable("document_collections").addColumn("documentId", "text", (col) => col.notNull()).addColumn("collectionId", "text", (col) => col.notNull()).addColumn("joinedOrdinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("leftOrdinal", "bigint").addPrimaryKeyConstraint("document_collections_pkey", ["documentId", "collectionId"]).execute();
3706
4117
  await db.schema.createIndex("idx_document_collections_collectionId").on("document_collections").column("collectionId").execute();
3707
4118
  await db.schema.createIndex("idx_doc_collections_collection_range").on("document_collections").columns(["collectionId", "joinedOrdinal"]).execute();
@@ -3715,8 +4126,8 @@ async function up$6(db) {
3715
4126
  }
3716
4127
  //#endregion
3717
4128
  //#region src/storage/migrations/010_create_sync_tables.ts
3718
- var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
3719
- async function up$5(db) {
4129
+ var _010_create_sync_tables_exports = /* @__PURE__ */ __exportAll({ up: () => up$6 });
4130
+ async function up$6(db) {
3720
4131
  await db.schema.createTable("sync_remotes").addColumn("name", "text", (col) => col.primaryKey()).addColumn("collection_id", "text", (col) => col.notNull()).addColumn("channel_type", "text", (col) => col.notNull()).addColumn("channel_id", "text", (col) => col.notNull().defaultTo("")).addColumn("remote_name", "text", (col) => col.notNull().defaultTo("")).addColumn("channel_parameters", "jsonb", (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)).addColumn("filter_document_ids", "jsonb").addColumn("filter_scopes", "jsonb").addColumn("filter_branch", "text", (col) => col.notNull().defaultTo("main")).addColumn("push_state", "text", (col) => col.notNull().defaultTo("idle")).addColumn("push_last_success_utc_ms", "text").addColumn("push_last_failure_utc_ms", "text").addColumn("push_failure_count", "integer", (col) => col.notNull().defaultTo(0)).addColumn("pull_state", "text", (col) => col.notNull().defaultTo("idle")).addColumn("pull_last_success_utc_ms", "text").addColumn("pull_last_failure_utc_ms", "text").addColumn("pull_failure_count", "integer", (col) => col.notNull().defaultTo(0)).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
3721
4132
  await db.schema.createIndex("idx_sync_remotes_collection").on("sync_remotes").column("collection_id").execute();
3722
4133
  await db.schema.createTable("sync_cursors").addColumn("remote_name", "text", (col) => col.primaryKey().references("sync_remotes.name").onDelete("cascade")).addColumn("cursor_ordinal", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("last_synced_at_utc_ms", "text").addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
@@ -3724,8 +4135,8 @@ async function up$5(db) {
3724
4135
  }
3725
4136
  //#endregion
3726
4137
  //#region src/storage/migrations/011_add_cursor_type_column.ts
3727
- var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
3728
- async function up$4(db) {
4138
+ var _011_add_cursor_type_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$5 });
4139
+ async function up$5(db) {
3729
4140
  await db.deleteFrom("sync_cursors").where("remote_name", "like", "outbox::%").execute();
3730
4141
  await db.deleteFrom("sync_remotes").where("name", "like", "outbox::%").execute();
3731
4142
  await db.schema.dropTable("sync_cursors").execute();
@@ -3734,43 +4145,60 @@ async function up$4(db) {
3734
4145
  }
3735
4146
  //#endregion
3736
4147
  //#region src/storage/migrations/012_add_source_remote_column.ts
3737
- var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
3738
- async function up$3(db) {
4148
+ var _012_add_source_remote_column_exports = /* @__PURE__ */ __exportAll({ up: () => up$4 });
4149
+ async function up$4(db) {
3739
4150
  await db.schema.alterTable("operation_index_operations").addColumn("sourceRemote", "text", (col) => col.notNull().defaultTo("")).execute();
3740
4151
  }
3741
4152
  //#endregion
3742
4153
  //#region src/storage/migrations/013_create_sync_dead_letters_table.ts
3743
- var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$2 });
3744
- async function up$2(db) {
4154
+ var _013_create_sync_dead_letters_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$3 });
4155
+ async function up$3(db) {
3745
4156
  await db.schema.createTable("sync_dead_letters").addColumn("ordinal", "serial", (col) => col.primaryKey()).addColumn("id", "text", (col) => col.unique().notNull()).addColumn("job_id", "text", (col) => col.notNull()).addColumn("job_dependencies", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("remote_name", "text", (col) => col.notNull().references("sync_remotes.name").onDelete("cascade")).addColumn("document_id", "text", (col) => col.notNull()).addColumn("scopes", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("branch", "text", (col) => col.notNull()).addColumn("operations", "jsonb", (col) => col.notNull().defaultTo(sql`'[]'::jsonb`)).addColumn("error_source", "text", (col) => col.notNull()).addColumn("error_message", "text", (col) => col.notNull()).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
3746
4157
  await db.schema.createIndex("idx_sync_dead_letters_remote").on("sync_dead_letters").column("remote_name").execute();
3747
4158
  }
3748
4159
  //#endregion
3749
4160
  //#region src/storage/migrations/014_create_processor_cursor_table.ts
3750
- var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$1 });
3751
- async function up$1(db) {
4161
+ var _014_create_processor_cursor_table_exports = /* @__PURE__ */ __exportAll({ up: () => up$2 });
4162
+ async function up$2(db) {
3752
4163
  await db.schema.createTable("ProcessorCursor").addColumn("processorId", "text", (col) => col.primaryKey()).addColumn("factoryId", "text", (col) => col.notNull()).addColumn("driveId", "text", (col) => col.notNull()).addColumn("processorIndex", "integer", (col) => col.notNull()).addColumn("lastOrdinal", "integer", (col) => col.notNull().defaultTo(sql`0`)).addColumn("status", "text", (col) => col.notNull().defaultTo(sql`'active'`)).addColumn("lastError", "text").addColumn("lastErrorTimestamp", "timestamptz").addColumn("createdAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).addColumn("updatedAt", "timestamptz", (col) => col.notNull().defaultTo(sql`NOW()`)).execute();
3753
4164
  }
3754
4165
  //#endregion
3755
4166
  //#region src/storage/migrations/015_add_operation_denied_reason.ts
3756
4167
  var _015_add_operation_denied_reason_exports = /* @__PURE__ */ __exportAll({
3757
- down: () => down,
3758
- up: () => up
4168
+ down: () => down$1,
4169
+ up: () => up$1
3759
4170
  });
3760
4171
  /**
3761
4172
  * Records why authorization refused an operation. Separate from `error` so a
3762
4173
  * denial is distinguishable from a reducer failure without matching on a
3763
4174
  * message. Null for every operation written before decisions were enforced.
3764
4175
  */
3765
- async function up(db) {
4176
+ async function up$1(db) {
3766
4177
  await db.schema.alterTable("Operation").addColumn("deniedReason", "text").execute();
3767
4178
  await db.schema.alterTable("operation_index_operations").addColumn("deniedReason", "text").execute();
3768
4179
  }
3769
- async function down(db) {
4180
+ async function down$1(db) {
3770
4181
  await db.schema.alterTable("operation_index_operations").dropColumn("deniedReason").execute();
3771
4182
  await db.schema.alterTable("Operation").dropColumn("deniedReason").execute();
3772
4183
  }
3773
4184
  //#endregion
4185
+ //#region src/storage/migrations/016_add_dead_letter_error_type.ts
4186
+ var _016_add_dead_letter_error_type_exports = /* @__PURE__ */ __exportAll({
4187
+ down: () => down,
4188
+ up: () => up
4189
+ });
4190
+ /**
4191
+ * The classification a dead letter falls into, stored because it decides whether
4192
+ * the document stays quarantined and the in-memory error is gone after a restart.
4193
+ * Defaulted rather than nullable, so a pre-existing row rehydrates.
4194
+ */
4195
+ async function up(db) {
4196
+ await db.schema.alterTable("sync_dead_letters").addColumn("error_type", "text", (col) => col.notNull().defaultTo("UNCLASSIFIED")).execute();
4197
+ }
4198
+ async function down(db) {
4199
+ await db.schema.alterTable("sync_dead_letters").dropColumn("error_type").execute();
4200
+ }
4201
+ //#endregion
3774
4202
  //#region src/storage/migrations/migrator.ts
3775
4203
  const REACTOR_SCHEMA = "reactor";
3776
4204
  const migrations = {
@@ -3788,7 +4216,8 @@ const migrations = {
3788
4216
  "012_add_source_remote_column": _012_add_source_remote_column_exports,
3789
4217
  "013_create_sync_dead_letters_table": _013_create_sync_dead_letters_table_exports,
3790
4218
  "014_create_processor_cursor_table": _014_create_processor_cursor_table_exports,
3791
- "015_add_operation_denied_reason": _015_add_operation_denied_reason_exports
4219
+ "015_add_operation_denied_reason": _015_add_operation_denied_reason_exports,
4220
+ "016_add_dead_letter_error_type": _016_add_dead_letter_error_type_exports
3792
4221
  };
3793
4222
  var ProgrammaticMigrationProvider = class {
3794
4223
  getMigrations() {
@@ -3842,6 +4271,6 @@ async function getMigrationStatus(db, schema = REACTOR_SCHEMA) {
3842
4271
  //#region src/core/drive-container-types.ts
3843
4272
  const DEFAULT_DRIVE_CONTAINER_TYPES = new Set(["powerhouse/document-drive", "powerhouse/reactor-drive"]);
3844
4273
  //#endregion
3845
- export { AuthorizationDeniedError as A, DuplicateOperationError as C, DuplicateModuleError as D, DuplicateManifestError as E, throwIfAborted as F, __exportAll as I, DocumentNotFoundError as M, matchesScope as N, InvalidModuleError as O, parsePagingOptions as P, AppendConditionFailedError as S, RevisionMismatchError as T, DocumentMetaCache as _, createForwardingPoolInstrumentation as a, CollectionMembershipCache as b, KyselyKeyframeStore as c, DriveCollectionId as d, buildDecisionModel as f, KyselyOperationIndex as g, KyselyWriteCache as h, runMigrations as i, DocumentDeletedError as j, ModuleNotFoundError as k, DocumentModelRegistry as l, EventBus as m, REACTOR_SCHEMA as n, instrumentPgPool as o, KyselyExecutionScope as p, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, SimpleJobExecutor as u, createConsistencyToken as v, OptimisticLockError as w, APPEND_CONDITION_FAILED_PREFIX as x, createEmptyConsistencyToken as y };
4274
+ export { OptimisticLockError as A, ExcessiveReshuffleError as B, DocumentMetaCache as C, APPEND_CONDITION_FAILED_PREFIX as D, CollectionMembershipCache as E, ModuleNotFoundError as F, __exportAll as G, matchesScope as H, AuthTimestampNotMonotonicError as I, AuthorizationDeniedError as L, DuplicateManifestError as M, DuplicateModuleError as N, AppendConditionFailedError as O, InvalidModuleError as P, DocumentDeletedError as R, KyselyOperationIndex as S, createEmptyConsistencyToken as T, parsePagingOptions as U, InvalidOperationTimestampError as V, throwIfAborted as W, KyselyExecutionScope as _, createForwardingPoolInstrumentation as a, EventBus as b, KyselyKeyframeStore as c, DriveCollectionId as d, decideAtHead as f, authDecisionModel as g, buildDecisionModel as h, runMigrations as i, RevisionMismatchError as j, DuplicateOperationError as k, DocumentModelRegistry as l, documentDecisionModel as m, REACTOR_SCHEMA as n, instrumentPgPool as o, selectDecisionModel as p, getMigrationStatus as r, KyselyOperationStore as s, DEFAULT_DRIVE_CONTAINER_TYPES as t, SimpleJobExecutor as u, FLAG_PREREQUISITES as v, createConsistencyToken as w, KyselyWriteCache as x, validateFeatureFlags as y, DocumentNotFoundError as z };
3846
4275
 
3847
- //# sourceMappingURL=drive-container-types-ZSLCC3lC.js.map
4276
+ //# sourceMappingURL=drive-container-types-BJCKXJwH.js.map