@docstack/client 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -3703,7 +3703,43 @@ const sys_017 = {
3703
3703
  }
3704
3704
  ]
3705
3705
  };
3706
- syspatches.push(sys_011, sys_012, sys_013, sys_014, sys_015, sys_016, sys_017);
3706
+ /**
3707
+ * Cryptographic access scopes arrive; the JS-rule policy engine retires
3708
+ * (ADR-0045, spec 02). `~AccessScope` is the carrier of the ONE access-control
3709
+ * language: a scope's CEK travels ABE-sealed under its attribute formula, and
3710
+ * denial is decryption failure - nothing evaluates rules anymore. The three
3711
+ * seeded `~Policy` documents are deactivated in place: nothing reads them, and
3712
+ * an inert-but-active policy would misstate what governs access. Their class
3713
+ * stays for legacy data.
3714
+ */
3715
+ const sys_018 = {
3716
+ "_id": "~sys-0.0.18",
3717
+ "~class": "patch",
3718
+ "version": "0.0.18",
3719
+ "target": "system",
3720
+ "changelog": "### Schema Patch: v0.0.18\\n#### New Class: ~AccessScope (cryptographic access, ADR-0045)\\n#### Deactivated: seeded ~Policy documents (policy engine retired)",
3721
+ "docs": [
3722
+ {
3723
+ "_id": "~AccessScope",
3724
+ "~class": "class",
3725
+ "active": true,
3726
+ "name": "AccessScope",
3727
+ "description": "A named set of content sealed under one CEK, itself ABE-encrypted under the scope's attribute policy (ADR-0045)",
3728
+ "schema": {
3729
+ "scopeId": { "name": "scopeId", "type": "string", "config": { "mandatory": true } },
3730
+ "policyString": { "name": "policyString", "type": "string", "config": { "mandatory": true } },
3731
+ "abeWrappedCek": { "name": "abeWrappedCek", "type": "string", "config": { "mandatory": true } },
3732
+ "kid": { "name": "kid", "type": "string", "config": { "mandatory": true, "maxLength": 16 } },
3733
+ "version": { "name": "version", "type": "integer", "config": { "mandatory": true } },
3734
+ "encryptedMarker": { "name": "encryptedMarker", "type": "object", "config": { "mandatory": true } }
3735
+ }
3736
+ },
3737
+ { "_id": "Policy-System-Classes", "_rev": "auto", "~class": "~Policy", "active": false },
3738
+ { "_id": "Policy-Admin", "_rev": "auto", "~class": "~Policy", "active": false },
3739
+ { "_id": "Policy-User-SelfAccess", "_rev": "auto", "~class": "~Policy", "active": false }
3740
+ ]
3741
+ };
3742
+ syspatches.push(sys_011, sys_012, sys_013, sys_014, sys_015, sys_016, sys_017, sys_018);
3707
3743
  /**
3708
3744
  * Every document id the system patches seed.
3709
3745
  *
@@ -3760,11 +3796,33 @@ const logger$4 = createLogger().child({ module: "pouchdb" });
3760
3796
  * ```
3761
3797
  */
3762
3798
  class StackLockedError extends Error {
3763
- constructor(className) {
3764
- super(`Stack is locked: '${className}' has encrypted attributes and no document key has been supplied, ` +
3765
- `so writing it would store those fields in the clear. Call 'stack.unlock(documentKey)' first.`);
3799
+ constructor(className, scopeId) {
3800
+ super(scopeId
3801
+ ? `Scope '${scopeId}' is sealed: '${className}' has encrypted attributes and the keyring holds no ` +
3802
+ `read-write key for the scope, so writing would seal under the wrong key or none. ` +
3803
+ `Call 'stack.unlockScopes(attributeKey)' first.`
3804
+ : `Stack is locked: '${className}' has encrypted attributes and no document key has been supplied, ` +
3805
+ `so writing it would store those fields in the clear. Call 'stack.unlock(documentKey)' first.`);
3766
3806
  this.name = "StackLockedError";
3767
3807
  this.className = className;
3808
+ this.scopeId = scopeId;
3809
+ }
3810
+ }
3811
+ /**
3812
+ * A write whose `~scope` label disagrees with its sealed payloads' key ids
3813
+ * (spec 02 §2.3 rule 2). Refused, never repaired: re-sealing content the
3814
+ * writer could not open under the labeled scope's key is exactly the
3815
+ * induced-downgrade attack a tampered label is fishing for. A legitimate
3816
+ * relabel opens the original scope first, so its payloads arrive as plaintext.
3817
+ */
3818
+ class StackScopeMismatchError extends Error {
3819
+ constructor(docId, scopeId, kid) {
3820
+ super(`Document '${docId}' is labeled scope '${scopeId}' but carries a sealed payload under key ` +
3821
+ `'${kid !== null && kid !== void 0 ? kid : "(unstamped)"}' that does not belong to the scope. Refusing to write: relabeling ` +
3822
+ `requires the original scope open, and a mismatch is quarantined, never re-sealed (ADR-0045).`);
3823
+ this.name = "StackScopeMismatchError";
3824
+ this.docId = docId;
3825
+ this.scopeId = scopeId;
3768
3826
  }
3769
3827
  }
3770
3828
  /**
@@ -4128,14 +4186,44 @@ const StackPlugin = (pouch, stack, pristine) => {
4128
4186
  }
4129
4187
  classCache.set(className, classObj);
4130
4188
  const encryptableAttributes = classObj.getEncryptedAttributes();
4131
- // A locked stack has no key, so encrypting is impossible and the
4132
- // fields would land in the clear. Refuse instead of degrading:
4133
- // silent plaintext is the failure mode ADR-0018 exists to remove.
4134
- // Bootstrap patches are the documented exception - the seed system
4189
+ // A sealed key cannot encrypt. Refuse instead of degrading:
4190
+ // silent plaintext is the failure mode ADR-0018 exists to remove,
4191
+ // and sealing under the WRONG key is its ADR-0045 sibling. A
4192
+ // scope-labeled document answers to its scope's CEK; an
4193
+ // unlabeled one to the legacy document key. Bootstrap patches
4194
+ // are the documented legacy-path exception - the seed system
4135
4195
  // user has to exist before any key can be recovered, and
4136
4196
  // `rekeyBootstrapDocuments` encrypts it once one arrives.
4137
- if (encryptableAttributes.length && stack.isLocked() && !(options === null || options === void 0 ? void 0 : options.isPatch)) {
4138
- throw new StackLockedError(className);
4197
+ if (encryptableAttributes.length && stack.cryptoEngine.isEnabled()) {
4198
+ const scopeLabel = stack.resolveScopeLabel(doc, classObj.model);
4199
+ if (scopeLabel) {
4200
+ if (!stack.cryptoEngine.isScopeWritable(scopeLabel)) {
4201
+ throw new StackLockedError(className, scopeLabel);
4202
+ }
4203
+ // The label↔kid mismatch guard (spec 02 §2.3 rule 2),
4204
+ // checked here beside the lock refusal so both are the
4205
+ // same pre-write rejection: a payload already sealed
4206
+ // under a key OUTSIDE the labeled scope is a relabel the
4207
+ // writer could not have performed legitimately (it never
4208
+ // opened the source), so it is refused - never re-sealed
4209
+ // under the labeled key, which is the induced downgrade.
4210
+ const scopeKids = await stack.getAccessScopeKids(scopeLabel);
4211
+ if (!scopeKids) {
4212
+ throw new StackScopeMismatchError(doc._id, scopeLabel, undefined);
4213
+ }
4214
+ for (const attribute of encryptableAttributes) {
4215
+ const value = doc[attribute.getName()];
4216
+ if (value && typeof value === "object" && value.__enc === true) {
4217
+ const kid = value.kid;
4218
+ if (!kid || !scopeKids.has(kid)) {
4219
+ throw new StackScopeMismatchError(doc._id, scopeLabel, kid);
4220
+ }
4221
+ }
4222
+ }
4223
+ }
4224
+ else if (stack.isLocked() && !(options === null || options === void 0 ? void 0 : options.isPatch)) {
4225
+ throw new StackLockedError(className);
4226
+ }
4139
4227
  }
4140
4228
  if (stack.cryptoEngine.isEnabled() && encryptableAttributes.length) {
4141
4229
  await stack.cryptoEngine.decryptDocument(doc, classObj);
@@ -4230,7 +4318,11 @@ const StackPlugin = (pouch, stack, pristine) => {
4230
4318
  }
4231
4319
  classCache.set(className, classObj);
4232
4320
  const clone = Object.assign({}, doc);
4233
- await stack.cryptoEngine.encryptDocument(clone, classObj);
4321
+ // The label was validated in the pre-write guard above
4322
+ // (scope known, writable, no foreign-kid payloads); here it
4323
+ // only selects the sealing key.
4324
+ const scopeLabel = stack.resolveScopeLabel(clone, classObj.model);
4325
+ await stack.cryptoEngine.encryptDocument(clone, classObj, scopeLabel);
4234
4326
  return clone;
4235
4327
  }
4236
4328
  }
@@ -8676,300 +8768,6 @@ class JobScheduler {
8676
8768
  }
8677
8769
  }
8678
8770
 
8679
- /**
8680
- * Set of system classes that bypass policy evaluation.
8681
- * These classes are internal to DocStack and always accessible.
8682
- */
8683
- const SYSTEM_CLASSES = new Set([
8684
- "~Policy",
8685
- "~Job",
8686
- "~JobRun",
8687
- "~AuthModule",
8688
- "~UserSession",
8689
- "class",
8690
- "domain"
8691
- ]);
8692
- /**
8693
- * Engine for evaluating access control policies on documents.
8694
- *
8695
- * PolicyEngine implements role-based access control (RBAC) by evaluating
8696
- * policy rules against documents and user sessions. Policies can be:
8697
- * - Class-level (apply to all documents of a class)
8698
- * - User-specific (apply only to a specific user)
8699
- * - Group-specific (apply only to users in a specific group)
8700
- *
8701
- * Policy rules are JavaScript expressions that receive the document,
8702
- * session, and groupId as context and return a boolean.
8703
- *
8704
- * @example
8705
- * ```typescript
8706
- * // Policy engine is used automatically during document operations
8707
- * // Policies are defined as documents:
8708
- * await stack.createDoc(null, '~Policy', null, {
8709
- * name: 'user-read-own',
8710
- * targetClass: ['User'],
8711
- * rule: 'return document.userId === session.userId;'
8712
- * });
8713
- * ```
8714
- */
8715
- class PolicyEngine {
8716
- /**
8717
- * Creates a new PolicyEngine instance.
8718
- * @param stack - The parent ClientStack instance
8719
- */
8720
- constructor(stack) {
8721
- /**
8722
- * Every `~Policy` document, loaded once and reused across evaluations.
8723
- *
8724
- * `isReadableDocument` runs once per document a read returns, and it used to re-fetch
8725
- * the policy list from the database each time - the dominant cost of every read path.
8726
- * The list is invalidated on any write that touches a `~Policy` document: the write
8727
- * path calls {@link invalidatePolicyCache} synchronously (see StackPlugin), and the
8728
- * stack's shared changes feed calls it again for out-of-band writes such as another
8729
- * tab's. `null` means not loaded.
8730
- */
8731
- this.allPoliciesCache = null;
8732
- /**
8733
- * Compiled policy rules, keyed by their source text.
8734
- *
8735
- * A rule is evaluated once per policy per document, and `new Function` is a full
8736
- * compile each time. The source text is the key - not the policy id - so two policies
8737
- * sharing a rule share the compilation, and an edited rule is simply a new key.
8738
- */
8739
- this.compiledRules = new Map();
8740
- this.stack = stack;
8741
- }
8742
- /**
8743
- * Drops the cached policy list so the next evaluation re-reads it.
8744
- * Called by the write path and the changes feed whenever a `~Policy` document lands.
8745
- */
8746
- invalidatePolicyCache() {
8747
- this.allPoliciesCache = null;
8748
- }
8749
- /**
8750
- * Gets the current authentication session proof.
8751
- * @returns The session proof, or undefined if not authenticated
8752
- */
8753
- getSessionProof() {
8754
- return this.stack.authSession;
8755
- }
8756
- /**
8757
- * Checks if a class should bypass policy evaluation.
8758
- * System classes (prefixed with ~) are always allowed.
8759
- *
8760
- * @param targetClass - The class name to check
8761
- * @returns `true` if the class should bypass policies
8762
- */
8763
- shouldBypass(targetClass) {
8764
- if (SYSTEM_CLASSES.has(targetClass))
8765
- return true;
8766
- const normalized = targetClass.startsWith("~") ? targetClass.slice(1) : `~${targetClass}`;
8767
- return SYSTEM_CLASSES.has(normalized);
8768
- }
8769
- async loadPolicies(targetClass, aliases = []) {
8770
- const identifiers = new Set([targetClass, ...aliases]);
8771
- if (this.allPoliciesCache === null) {
8772
- // Read raw rather than through `findDocuments`: policies are a system class
8773
- // that bypasses policy evaluation anyway, and going through the read path
8774
- // here recursed into a policy check per policy document. The selector is the
8775
- // same one `findDocuments` produced (it injects `active: true`), and that is
8776
- // the contract, not an accident: a policy enforces only while `active: true`,
8777
- // exactly as a document is visible only while active - an unflagged or
8778
- // explicitly inactive policy does not apply (ADR-0032). The explicit limit is
8779
- // because pouchdb-find otherwise silently caps results at 25, which for
8780
- // policies means silently not enforcing the 26th.
8781
- const result = await this.stack.db.find({
8782
- selector: { "~class": "~Policy", active: true },
8783
- limit: 2 ** 31 - 1,
8784
- });
8785
- this.allPoliciesCache = result.docs;
8786
- }
8787
- return this.allPoliciesCache.filter((doc) => {
8788
- if (!Array.isArray(doc.targetClass))
8789
- return false;
8790
- return doc.targetClass.some((entry) => identifiers.has(entry));
8791
- });
8792
- }
8793
- getTargetIdentifiers(targetId, targetName) {
8794
- const identifiers = new Set();
8795
- const variants = [targetId, targetName];
8796
- for (const value of variants) {
8797
- identifiers.add(value);
8798
- if (value.startsWith("~")) {
8799
- identifiers.add(value.slice(1));
8800
- }
8801
- else {
8802
- identifiers.add(`~${value}`);
8803
- }
8804
- }
8805
- return Array.from(identifiers);
8806
- }
8807
- async resolveClassTarget(targetClass) {
8808
- var _a, _b;
8809
- const classModel = await this.stack.getClassModel(targetClass).catch(() => null);
8810
- return {
8811
- id: (_a = classModel === null || classModel === void 0 ? void 0 : classModel._id) !== null && _a !== void 0 ? _a : targetClass,
8812
- name: (_b = classModel === null || classModel === void 0 ? void 0 : classModel.name) !== null && _b !== void 0 ? _b : targetClass,
8813
- };
8814
- }
8815
- /**
8816
- * Evaluates a policy rule against a document and session.
8817
- * The rule is a JavaScript expression that returns a boolean.
8818
- *
8819
- * @param policy - The policy containing the rule
8820
- * @param document - The document being accessed
8821
- * @param session - The current auth session
8822
- * @returns Whether the rule permits access
8823
- */
8824
- async evaluateRule(policy, document, session) {
8825
- let executor = this.compiledRules.get(policy.rule);
8826
- if (!executor) {
8827
- executor = new Function("document", "session", "groupId", `"use strict"; ${policy.rule}`);
8828
- this.compiledRules.set(policy.rule, executor);
8829
- }
8830
- const result = executor(document || {}, session.session, session.session.groupId);
8831
- if (result instanceof Promise) {
8832
- return Boolean(await result);
8833
- }
8834
- return Boolean(result);
8835
- }
8836
- filterPoliciesForSession(policies, session) {
8837
- const sessionUserId = session.session.userId || session.session.username;
8838
- const sessionGroups = Array.isArray(session.session.groupId)
8839
- ? session.session.groupId
8840
- : session.session.groupId
8841
- ? [session.session.groupId]
8842
- : [];
8843
- return policies.filter((policy) => {
8844
- const matchesUser = !policy.userId || policy.userId === sessionUserId || policy.userId === session.session.username;
8845
- const matchesGroup = !policy.groupId || sessionGroups.includes(policy.groupId);
8846
- return matchesUser && matchesGroup;
8847
- });
8848
- }
8849
- async authorize(targetClass, operation, document) {
8850
- const { id: targetId, name: targetName } = await this.resolveClassTarget(targetClass);
8851
- const identifiers = this.getTargetIdentifiers(targetId, targetName);
8852
- if (this.shouldBypass(targetName)) {
8853
- return true;
8854
- }
8855
- const policies = await this.loadPolicies(targetId, identifiers);
8856
- if (policies.length === 0) {
8857
- return true;
8858
- }
8859
- const session = this.getSessionProof();
8860
- if (!session) {
8861
- throw new Error("Stack is not authenticated for policy evaluation");
8862
- }
8863
- const targetedPolicies = policies.filter((policy) => policy.userId || policy.groupId);
8864
- const basePolicies = policies.filter((policy) => !policy.userId && !policy.groupId);
8865
- const matchingPolicies = targetedPolicies.length > 0
8866
- ? this.filterPoliciesForSession(targetedPolicies, session)
8867
- : this.filterPoliciesForSession(basePolicies, session);
8868
- if (targetedPolicies.length > 0 && matchingPolicies.length === 0) {
8869
- throw new Error(`No matching policy allowed ${operation} on class '${targetClass}'`);
8870
- }
8871
- let allowed = false;
8872
- for (const policy of matchingPolicies) {
8873
- const result = await this.evaluateRule(policy, document, session);
8874
- if (result === false) {
8875
- throw new Error(`Policy '${policy._id}' denied ${operation} on class '${targetClass}'`);
8876
- }
8877
- if (result === true) {
8878
- allowed = true;
8879
- }
8880
- }
8881
- return allowed;
8882
- }
8883
- /**
8884
- * Whether any policy applies to a class - i.e. whether reads of it are filtered.
8885
- *
8886
- * Lets the query engine know when a database-level LIMIT is safe: with no
8887
- * applicable policies, no row fetched within the limit can be dropped afterwards.
8888
- * Cheap once the policy list is cached.
8889
- *
8890
- * @param targetClass - The class name or id.
8891
- * @returns `true` if at least one policy targets the class.
8892
- */
8893
- async hasPoliciesFor(targetClass) {
8894
- const { id: targetId, name: targetName } = await this.resolveClassTarget(targetClass);
8895
- if (this.shouldBypass(targetName))
8896
- return false;
8897
- const policies = await this.loadPolicies(targetId, this.getTargetIdentifiers(targetId, targetName));
8898
- return policies.length > 0;
8899
- }
8900
- /**
8901
- * Ensures write access is allowed for a document of the given class.
8902
- * Throws an error if no policy permits the write operation.
8903
- *
8904
- * @param targetClass - The class of the document being written
8905
- * @param document - The document to write
8906
- * @throws Error if write is not permitted
8907
- *
8908
- * @example
8909
- * ```typescript
8910
- * // Called automatically during createDoc/updateCard operations
8911
- * await policyEngine.ensureWriteAllowed('Task', taskDocument);
8912
- * ```
8913
- */
8914
- async ensureWriteAllowed(targetClass, document) {
8915
- const allowed = await this.authorize(targetClass, "write", document);
8916
- if (!allowed) {
8917
- throw new Error(`No matching policy allowed write on class '${targetClass}'`);
8918
- }
8919
- }
8920
- /**
8921
- * Checks if a document is readable by the current user.
8922
- * Used to filter query results based on read policies.
8923
- *
8924
- * @param document - The document to check
8925
- * @returns `true` if the document can be read
8926
- * @throws Error if the stack is not authenticated
8927
- *
8928
- * @example
8929
- * ```typescript
8930
- * // Used internally during findDocuments
8931
- * const readable = await policyEngine.isReadableDocument(doc);
8932
- * if (readable) {
8933
- * results.push(doc);
8934
- * }
8935
- * ```
8936
- */
8937
- async isReadableDocument(document) {
8938
- const targetClass = document === null || document === void 0 ? void 0 : document["~class"];
8939
- const { id: targetId, name: targetName } = await this.resolveClassTarget(targetClass);
8940
- if (!targetClass || this.shouldBypass(targetName)) {
8941
- return true;
8942
- }
8943
- const policies = await this.loadPolicies(targetId, this.getTargetIdentifiers(targetId, targetName));
8944
- if (policies.length === 0) {
8945
- return true;
8946
- }
8947
- const session = this.getSessionProof();
8948
- if (!session) {
8949
- throw new Error("Stack is not authenticated for policy evaluation");
8950
- }
8951
- const targetedPolicies = policies.filter((policy) => policy.userId || policy.groupId);
8952
- const basePolicies = policies.filter((policy) => !policy.userId && !policy.groupId);
8953
- const matchingPolicies = targetedPolicies.length > 0
8954
- ? this.filterPoliciesForSession(targetedPolicies, session)
8955
- : this.filterPoliciesForSession(basePolicies, session);
8956
- if (targetedPolicies.length > 0 && matchingPolicies.length === 0) {
8957
- return false;
8958
- }
8959
- let permitted = false;
8960
- for (const policy of matchingPolicies) {
8961
- const result = await this.evaluateRule(policy, document, session);
8962
- if (result === false) {
8963
- return false;
8964
- }
8965
- if (result === true) {
8966
- permitted = true;
8967
- }
8968
- }
8969
- return permitted;
8970
- }
8971
- }
8972
-
8973
8771
  const encoder = new TextEncoder();
8974
8772
  const decoder = new TextDecoder();
8975
8773
  const getCrypto = () => globalThis.crypto;
@@ -9037,12 +8835,23 @@ const deriveKeyId = async (hexKey) => {
9037
8835
  .map(b => b.toString(16).padStart(2, "0"))
9038
8836
  .join("");
9039
8837
  };
9040
- const encryptWithAesGcm = async (plaintext, key, kid) => {
8838
+ /**
8839
+ * The additional-authenticated-data string binding a scope-sealed payload to
8840
+ * its label (spec 02 §2.3 rule 3): decryption derives it from the DOCUMENT's
8841
+ * `~scope` at read time, so a tampered or stripped label does not merely look
8842
+ * inconsistent - the GCM authentication fails and the payload stays sealed.
8843
+ * Legacy-key payloads pass no AAD, keeping pre-scope ciphertext readable.
8844
+ */
8845
+ const scopeAad = (scopeId, kid) => `${scopeId}|${kid}`;
8846
+ const encryptWithAesGcm = async (plaintext, key, kid, aad) => {
9041
8847
  const cryptoObj = getCrypto();
9042
8848
  const iv = new Uint8Array(12);
9043
8849
  cryptoObj.getRandomValues(iv);
9044
8850
  const ivBuffer = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength);
9045
- const ciphertext = await cryptoObj.subtle.encrypt({ name: "AES-GCM", iv: ivBuffer }, key, encoder.encode(plaintext));
8851
+ const params = { name: "AES-GCM", iv: ivBuffer };
8852
+ if (aad)
8853
+ params.additionalData = encoder.encode(aad);
8854
+ const ciphertext = await cryptoObj.subtle.encrypt(params, key, encoder.encode(plaintext));
9046
8855
  const payload = {
9047
8856
  __enc: true,
9048
8857
  iv: toBase64(iv),
@@ -9053,18 +8862,23 @@ const encryptWithAesGcm = async (plaintext, key, kid) => {
9053
8862
  payload.kid = kid;
9054
8863
  return payload;
9055
8864
  };
9056
- const decryptWithAesGcm = async (payload, key) => {
8865
+ const decryptWithAesGcm = async (payload, key, aad) => {
9057
8866
  const cryptoObj = getCrypto();
9058
8867
  const ivBytes = fromBase64(payload.iv);
9059
8868
  const dataBytes = fromBase64(payload.data);
9060
8869
  const ivBuffer = ivBytes.buffer.slice(ivBytes.byteOffset, ivBytes.byteOffset + ivBytes.byteLength);
9061
8870
  const dataBuffer = dataBytes.buffer.slice(dataBytes.byteOffset, dataBytes.byteOffset + dataBytes.byteLength);
9062
- const decrypted = await cryptoObj.subtle.decrypt({ name: "AES-GCM", iv: ivBuffer }, key, dataBuffer);
8871
+ const params = { name: "AES-GCM", iv: ivBuffer };
8872
+ if (aad)
8873
+ params.additionalData = encoder.encode(aad);
8874
+ const decrypted = await cryptoObj.subtle.decrypt(params, key, dataBuffer);
9063
8875
  return decoder.decode(decrypted);
9064
8876
  };
9065
8877
  const wrapDocumentKey = async (documentKey, derivedKeyHex) => {
9066
8878
  const key = await importAesKeyFromHex(derivedKeyHex);
9067
- const payload = await encryptWithAesGcm(documentKey, key);
8879
+ // Stamped with the WRAPPED key's id, so wrapped key material is
8880
+ // self-identifying (spec 02 §3): a user record can say which key it holds.
8881
+ const payload = await encryptWithAesGcm(documentKey, key, await deriveKeyId(documentKey));
9068
8882
  return JSON.stringify(payload);
9069
8883
  };
9070
8884
  const unwrapDocumentKey = async (wrappedDocumentKey, cryptoKey, derivedKeyHex) => {
@@ -9127,6 +8941,18 @@ class CryptoEngine {
9127
8941
  * interrupted, and resumed.
9128
8942
  */
9129
8943
  this.retiredKeys = new Map();
8944
+ /**
8945
+ * Scope CEKs admitted by {@link admitScopeKey}, by key id (ADR-0045).
8946
+ *
8947
+ * The keyring the single document key generalizes into: reads dispatch by a
8948
+ * payload's `kid` across the legacy key, retired keys, and these; writes
8949
+ * into a scope-labeled document select through {@link scopeWriteKeys}. A
8950
+ * scope whose CEK is absent here is simply LOCKED - its payloads stay
8951
+ * sealed, which is the access decision.
8952
+ */
8953
+ this.scopeKeys = new Map();
8954
+ /** The read-write entry per scope id - the CEK that seals new writes. */
8955
+ this.scopeWriteKeys = new Map();
9130
8956
  this.logger = createLogger().child({ module: "crypto-engine" });
9131
8957
  this.stack = stack;
9132
8958
  this.enabled = !stack.isCryptoEngineDisabled();
@@ -9206,8 +9032,94 @@ class CryptoEngine {
9206
9032
  return [
9207
9033
  ...(this.documentKeyId ? [this.documentKeyId] : []),
9208
9034
  ...this.retiredKeys.keys(),
9035
+ ...this.scopeKeys.keys(),
9209
9036
  ];
9210
9037
  }
9038
+ /**
9039
+ * Admits a scope's CEK into the keyring after its canary verified
9040
+ * (spec 02 §2.1 - admission is the caller's `verifyScopeCek` first, this
9041
+ * second). The winning version of a scope enters read-write and becomes
9042
+ * the key new writes into the scope seal under; older versions enter
9043
+ * read-only, the retired-keys discipline applied per scope.
9044
+ *
9045
+ * @returns The admitted key's id.
9046
+ */
9047
+ async admitScopeKey(scopeId, cekHex, version, mode) {
9048
+ if (!this.enabled)
9049
+ throw new Error("Crypto engine is disabled");
9050
+ const kid = await deriveKeyId(cekHex);
9051
+ const entry = {
9052
+ kid,
9053
+ cryptoKey: await importAesKeyFromHex(cekHex, mode === "read-write" ? ["encrypt", "decrypt"] : ["decrypt"]),
9054
+ scopeId,
9055
+ version,
9056
+ mode,
9057
+ };
9058
+ this.scopeKeys.set(kid, entry);
9059
+ if (mode === "read-write") {
9060
+ const current = this.scopeWriteKeys.get(scopeId);
9061
+ if (!current || current.version <= version) {
9062
+ if (current && current.kid !== kid) {
9063
+ // Superseded by a higher rotation version: keep it readable.
9064
+ this.scopeKeys.set(current.kid, Object.assign(Object.assign({}, current), { mode: "read-only" }));
9065
+ }
9066
+ this.scopeWriteKeys.set(scopeId, entry);
9067
+ }
9068
+ else {
9069
+ // A lower version arriving late reads, never writes.
9070
+ this.scopeKeys.set(kid, Object.assign(Object.assign({}, entry), { mode: "read-only" }));
9071
+ }
9072
+ }
9073
+ return kid;
9074
+ }
9075
+ /**
9076
+ * Tests a candidate CEK against a scope's canary WITHOUT admitting it -
9077
+ * the ADR-0018 admission discipline per scope: a corrupted or
9078
+ * rotated-away ciphertext is an error at unlock, not garbage later. The
9079
+ * marker's AAD binds it to the scope and key it was minted for.
9080
+ */
9081
+ async verifyScopeCek(scopeId, cekHex, marker) {
9082
+ if (!this.enabled)
9083
+ return false;
9084
+ if (!isEncryptedPayload(marker))
9085
+ return false;
9086
+ try {
9087
+ const kid = await deriveKeyId(cekHex);
9088
+ const key = await importAesKeyFromHex(cekHex, ["decrypt"]);
9089
+ await decryptWithAesGcm(marker, key, scopeAad(scopeId, kid));
9090
+ return true;
9091
+ }
9092
+ catch (_a) {
9093
+ return false;
9094
+ }
9095
+ }
9096
+ /** Whether new writes into this scope can seal - a read-write CEK is held. */
9097
+ isScopeWritable(scopeId) {
9098
+ return this.enabled && this.scopeWriteKeys.has(scopeId);
9099
+ }
9100
+ /** Scope ids holding a read-write CEK. */
9101
+ unlockedScopeIds() {
9102
+ return this.enabled ? [...this.scopeWriteKeys.keys()] : [];
9103
+ }
9104
+ /** The key id new writes into a scope seal under, when the scope is open. */
9105
+ getScopeWriteKeyId(scopeId) {
9106
+ var _a;
9107
+ return (_a = this.scopeWriteKeys.get(scopeId)) === null || _a === void 0 ? void 0 : _a.kid;
9108
+ }
9109
+ /** The scope a held key id belongs to, if it is a scope key. */
9110
+ scopeOfKid(kid) {
9111
+ var _a;
9112
+ return (_a = this.scopeKeys.get(kid)) === null || _a === void 0 ? void 0 : _a.scopeId;
9113
+ }
9114
+ /**
9115
+ * Drops every admitted scope CEK - the session's material is gone, the
9116
+ * scopes are locked again. Mirrors what clearing the document key does for
9117
+ * the legacy path.
9118
+ */
9119
+ dropScopeKeys() {
9120
+ this.scopeKeys.clear();
9121
+ this.scopeWriteKeys.clear();
9122
+ }
9211
9123
  /**
9212
9124
  * Generates a cryptographically secure random string.
9213
9125
  * Useful for generating salts or nonces.
@@ -9309,22 +9221,29 @@ class CryptoEngine {
9309
9221
  return this.cryptoKey;
9310
9222
  }
9311
9223
  /**
9312
- * Chooses the key that can open a payload.
9224
+ * Chooses the keyring entry that can open a payload.
9313
9225
  *
9314
9226
  * A payload names its key, so an old field found mid-re-key is decrypted with the key
9315
9227
  * it was actually written under instead of failing against the current one. Payloads
9316
- * from before identifiers existed name nothing, and are tried against the current key
9317
- * - which is what they meant when only one key could exist.
9228
+ * from before identifiers existed name nothing, and are tried against the LEGACY
9229
+ * document key only (spec 02 §3): under multiple keys, falling back to "whatever is
9230
+ * current" would silently mis-route - a scope key never answers for an unnamed
9231
+ * payload.
9318
9232
  */
9319
- async resolveKeyFor(payload, fallback) {
9320
- var _a;
9321
- if (!payload.kid)
9322
- return fallback;
9323
- if (payload.kid === this.documentKeyId)
9324
- return fallback;
9325
- return (_a = this.retiredKeys.get(payload.kid)) !== null && _a !== void 0 ? _a : null;
9233
+ async resolveEntryFor(payload) {
9234
+ if (!payload.kid || payload.kid === this.documentKeyId) {
9235
+ const legacy = await this.getCryptoKey();
9236
+ return legacy ? { key: legacy } : null;
9237
+ }
9238
+ const retired = this.retiredKeys.get(payload.kid);
9239
+ if (retired)
9240
+ return { key: retired };
9241
+ const scope = this.scopeKeys.get(payload.kid);
9242
+ if (scope)
9243
+ return { key: scope.cryptoKey, scope };
9244
+ return null;
9326
9245
  }
9327
- async encryptValue(value, key) {
9246
+ async encryptValue(value, key, kid, aad) {
9328
9247
  if (!this.enabled)
9329
9248
  return value;
9330
9249
  if (!key)
@@ -9334,26 +9253,38 @@ class CryptoEngine {
9334
9253
  if (isEncryptedPayload(value))
9335
9254
  return value;
9336
9255
  const serialized = JSON.stringify(value);
9337
- return encryptWithAesGcm(serialized, key, this.documentKeyId);
9256
+ return encryptWithAesGcm(serialized, key, kid !== null && kid !== void 0 ? kid : this.documentKeyId, aad);
9338
9257
  }
9339
- async decryptValue(value, key) {
9258
+ /**
9259
+ * @param label - The document's `~scope` at read time. A scope-sealed
9260
+ * payload authenticates against it (AAD, spec 02 §2.3 rule 3): a tampered
9261
+ * or stripped label fails the GCM authentication and the payload stays
9262
+ * sealed - the mismatch is detected, never silently honored.
9263
+ */
9264
+ async decryptValue(value, label) {
9340
9265
  if (!this.enabled)
9341
9266
  return value;
9342
- if (!key)
9343
- return value;
9344
9267
  if (!isEncryptedPayload(value))
9345
9268
  return value;
9346
- const resolved = await this.resolveKeyFor(value, key);
9269
+ const resolved = await this.resolveEntryFor(value);
9347
9270
  if (!resolved) {
9348
9271
  this.logger.warn("No held key matches this payload; leaving it encrypted", { kid: value.kid });
9349
9272
  return value;
9350
9273
  }
9274
+ const aad = resolved.scope ? scopeAad(label !== null && label !== void 0 ? label : "", value.kid) : undefined;
9351
9275
  try {
9352
- const decrypted = await decryptWithAesGcm(value, resolved);
9276
+ const decrypted = await decryptWithAesGcm(value, resolved.key, aad);
9353
9277
  return JSON.parse(decrypted);
9354
9278
  }
9355
9279
  catch (error) {
9356
- this.logger.error("Failed to decrypt value", { error: (error === null || error === void 0 ? void 0 : error.message) || error });
9280
+ if (resolved.scope) {
9281
+ this.logger.warn("Scope payload does not authenticate against the document's label; leaving it sealed", {
9282
+ kid: value.kid, scopeOfKey: resolved.scope.scopeId, label: label !== null && label !== void 0 ? label : null,
9283
+ });
9284
+ }
9285
+ else {
9286
+ this.logger.error("Failed to decrypt value", { error: (error === null || error === void 0 ? void 0 : error.message) || error });
9287
+ }
9357
9288
  return value;
9358
9289
  }
9359
9290
  }
@@ -9388,23 +9319,46 @@ class CryptoEngine {
9388
9319
  * @param document - The document to encrypt
9389
9320
  * @param classObj - The class defining which fields to encrypt
9390
9321
  */
9391
- async encryptDocument(document, classObj) {
9322
+ /**
9323
+ * @param scopeId - The document's resolved scope label. When present, the
9324
+ * scope's read-write CEK seals every attribute (stamped with its `kid`,
9325
+ * bound to the label via AAD); the caller has already refused the write if
9326
+ * the scope is not open. Absent, the legacy document key path applies
9327
+ * unchanged.
9328
+ */
9329
+ async encryptDocument(document, classObj, scopeId) {
9392
9330
  var _a, _b, _c;
9393
9331
  if (!this.enabled)
9394
9332
  return;
9395
9333
  const encryptableAttributes = classObj.getEncryptedAttributes();
9396
9334
  if (!encryptableAttributes.length)
9397
9335
  return;
9398
- const key = await this.getCryptoKey();
9399
- if (!key) {
9400
- this.logger.warn("Document key is not available; skipping encryption", { className: (_b = (_a = classObj.getName) === null || _a === void 0 ? void 0 : _a.call(classObj)) !== null && _b !== void 0 ? _b : (_c = classObj.model) === null || _c === void 0 ? void 0 : _c.name });
9401
- return;
9336
+ let key;
9337
+ let kid;
9338
+ let aad;
9339
+ if (scopeId) {
9340
+ const entry = this.scopeWriteKeys.get(scopeId);
9341
+ if (!entry) {
9342
+ // The plugin refuses scope writes before reaching here; this is the
9343
+ // engine's own last line - sealing under the wrong key is never a fallback.
9344
+ throw new Error(`Scope '${scopeId}' holds no read-write key; cannot encrypt.`);
9345
+ }
9346
+ key = entry.cryptoKey;
9347
+ kid = entry.kid;
9348
+ aad = scopeAad(scopeId, entry.kid);
9349
+ }
9350
+ else {
9351
+ key = await this.getCryptoKey();
9352
+ if (!key) {
9353
+ this.logger.warn("Document key is not available; skipping encryption", { className: (_b = (_a = classObj.getName) === null || _a === void 0 ? void 0 : _a.call(classObj)) !== null && _b !== void 0 ? _b : (_c = classObj.model) === null || _c === void 0 ? void 0 : _c.name });
9354
+ return;
9355
+ }
9402
9356
  }
9403
9357
  for (const attribute of encryptableAttributes) {
9404
9358
  const name = attribute.getName();
9405
9359
  if (!(name in document))
9406
9360
  continue;
9407
- const encrypted = await this.encryptValue(document[name], key);
9361
+ const encrypted = await this.encryptValue(document[name], key, kid, aad);
9408
9362
  document[name] = encrypted;
9409
9363
  }
9410
9364
  }
@@ -9417,19 +9371,18 @@ class CryptoEngine {
9417
9371
  * @param encryptedKeys - Optional pre-computed list of encrypted field names
9418
9372
  */
9419
9373
  async decryptDocument(document, classObj, encryptedKeys) {
9420
- var _a, _b, _c;
9421
9374
  if (!this.enabled)
9422
9375
  return;
9423
9376
  const encryptedAttributes = encryptedKeys !== null && encryptedKeys !== void 0 ? encryptedKeys : this.identifyEncryptedKeys(document, classObj);
9424
9377
  if (!encryptedAttributes.length)
9425
9378
  return;
9426
- const key = await this.getCryptoKey();
9427
- if (!key) {
9428
- this.logger.warn("Document key is not available; returning encrypted payload", { className: (_b = (_a = classObj === null || classObj === void 0 ? void 0 : classObj.getName) === null || _a === void 0 ? void 0 : _a.call(classObj)) !== null && _b !== void 0 ? _b : (_c = classObj === null || classObj === void 0 ? void 0 : classObj.model) === null || _c === void 0 ? void 0 : _c.name });
9429
- return;
9430
- }
9379
+ // No early bail on a missing legacy key: the keyring dispatches per
9380
+ // payload, and a scope key can open what the document key cannot. A
9381
+ // payload nothing opens stays sealed - which IS the (per-scope) locked
9382
+ // read, handled by the read paths' null convention.
9383
+ const label = document["~scope"];
9431
9384
  for (const name of encryptedAttributes) {
9432
- const decrypted = await this.decryptValue(document[name], key);
9385
+ const decrypted = await this.decryptValue(document[name], typeof label === "string" ? label : undefined);
9433
9386
  document[name] = decrypted;
9434
9387
  }
9435
9388
  }
@@ -9665,15 +9618,8 @@ const sweepEntry = async (stack, stage, entry, options) => {
9665
9618
  if (isPatch(doc)) {
9666
9619
  throw new TransactionUnsupportedDocError(docId, "patches carry class models and apply through 'applyPatch'.");
9667
9620
  }
9668
- // A hard delete carries no content to validate; write access is still the
9669
- // author's to prove - unless this is DocStack's own machinery (an internal
9670
- // handle: patch application runs before any session exists, and the patch
9671
- // path's direct writes never pass through policy either - ADR-0044).
9621
+ // A hard delete carries no content to validate.
9672
9622
  if (entry.op === "delete") {
9673
- const type = doc["~class"];
9674
- if (typeof type === "string" && !(options === null || options === void 0 ? void 0 : options.skipPolicy)) {
9675
- await stack.policyEngine.ensureWriteAllowed(type, doc);
9676
- }
9677
9623
  return;
9678
9624
  }
9679
9625
  if (isRelation(doc)) {
@@ -9704,18 +9650,24 @@ const sweepEntry = async (stack, stage, entry, options) => {
9704
9650
  if (!classObj) {
9705
9651
  throw new TransactionValidationError(`Class '${type}' not found for document '${docId}'.`, docId);
9706
9652
  }
9707
- // Same refusal the plugin makes (ADR-0018): a locked stack cannot encrypt, and
9708
- // committing later while still locked would land the fields in the clear.
9709
- if (classObj.getEncryptedAttributes().length && stack.isLocked()) {
9710
- throw new StackLockedError(type);
9653
+ // Same refusal the plugin makes (ADR-0018, per scope since ADR-0045): a
9654
+ // sealed key cannot encrypt, and committing later while still sealed would
9655
+ // land the fields in the clear - or under the wrong key, which is worse.
9656
+ if (classObj.getEncryptedAttributes().length) {
9657
+ const label = stack.resolveScopeLabel(doc, classObj.model);
9658
+ if (label) {
9659
+ if (!stack.cryptoEngine.isScopeWritable(label)) {
9660
+ throw new StackLockedError(type, label);
9661
+ }
9662
+ }
9663
+ else if (stack.isLocked()) {
9664
+ throw new StackLockedError(type);
9665
+ }
9711
9666
  }
9712
9667
  const valid = await classObj.validate(doc);
9713
9668
  if (!valid) {
9714
9669
  throw new TransactionValidationError(`Document '${docId}' does not validate against class '${type}'.`, docId);
9715
9670
  }
9716
- if (!(options === null || options === void 0 ? void 0 : options.skipPolicy)) {
9717
- await stack.policyEngine.ensureWriteAllowed(type, doc);
9718
- }
9719
9671
  };
9720
9672
 
9721
9673
  class PouchError extends Error {
@@ -12088,7 +12040,7 @@ class TransactionHandle {
12088
12040
  stagedAt: Date.now(),
12089
12041
  };
12090
12042
  delete entry.doc._rev;
12091
- await sweepEntry(this.stack, this.stage, entry, { allowClassModels: this.internal, skipPolicy: this.internal });
12043
+ await sweepEntry(this.stack, this.stage, entry, { allowClassModels: this.internal });
12092
12044
  this.stage.set(docId, entry);
12093
12045
  return this.stage.get(docId);
12094
12046
  }
@@ -12415,7 +12367,7 @@ class TransactionEngine {
12415
12367
  // can be stale (a policy changed, a class tightened). Zero consequences on
12416
12368
  // refusal.
12417
12369
  for (const entry of entries) {
12418
- await sweepEntry(this.stack, stage, entry, { allowClassModels: handle.internal, skipPolicy: handle.internal });
12370
+ await sweepEntry(this.stack, stage, entry, { allowClassModels: handle.internal });
12419
12371
  }
12420
12372
  // 2. Rev pre-flight: every staged id's stored winner must still be the
12421
12373
  // revision it was staged against. `allDocs` is below the plugin - revs
@@ -12643,6 +12595,7 @@ const DOCSTACK_OPTION_KEYS = [
12643
12595
  "disableCryptoEngine",
12644
12596
  "documentKey",
12645
12597
  "transactions",
12598
+ "accessKeys",
12646
12599
  "logLevel",
12647
12600
  ];
12648
12601
  /**
@@ -12792,6 +12745,19 @@ class ClientStack extends Stack {
12792
12745
  */
12793
12746
  this.classDocSubscribers = new Map();
12794
12747
  this.modelWorker = null;
12748
+ /**
12749
+ * Engine for enforcing read/write access control policies.
12750
+ * Policies are evaluated based on user session and document content.
12751
+ */
12752
+ /**
12753
+ * The access-scope registry: every `~AccessScope` document, grouped by
12754
+ * `scopeId` (ADR-0045). Built from the database, independent of which CEKs
12755
+ * the keyring actually holds - the registry knows a scope's key ids even
12756
+ * when the session cannot open them, which is what the label↔kid mismatch
12757
+ * guard needs (spec 02 §2.3 rule 2).
12758
+ */
12759
+ this.accessScopeRegistry = null;
12760
+ this.accessScopeRegistryDirty = true;
12795
12761
  /**
12796
12762
  * Application patches held back because they write encrypted attributes and the stack
12797
12763
  * has no key yet. Replayed by {@link unlock}. Kept in memory on purpose - reopening
@@ -12956,10 +12922,12 @@ class ClientStack extends Stack {
12956
12922
  throw new Error(`exportContent - no such content class: ${unknown.join(", ")}`);
12957
12923
  }
12958
12924
  }
12959
- // A locked stack reads encrypted attributes back as `null`. Writing that into an
12960
- // export would lose data in a way nothing downstream could detect, so it is
12961
- // refused unless the caller has said they want the rest anyway.
12962
- if (this.isLocked() && !options.allowLossyWhenLocked) {
12925
+ // A sealed payload reads back as `null` - a locked legacy key, or a
12926
+ // scope the keyring cannot open. Writing that into an export would lose
12927
+ // data in a way nothing downstream could detect, so it is refused
12928
+ // unless the caller has said they want the rest anyway, naming what is
12929
+ // sealed (spec 02 §5: lossy per locked scope, and the report says which).
12930
+ if (!options.allowLossyWhenLocked) {
12963
12931
  const encrypted = [];
12964
12932
  for (const className of classes) {
12965
12933
  const classObj = await this.getClassSnapshot(className);
@@ -12967,9 +12935,17 @@ class ClientStack extends Stack {
12967
12935
  encrypted.push(className);
12968
12936
  }
12969
12937
  if (encrypted.length) {
12970
- throw new Error(`exportContent - the stack is locked, so encrypted attributes on ${encrypted.join(", ")} ` +
12971
- "would be exported as null. Unlock it with 'stack.unlock(documentKey)', or pass " +
12972
- "'allowLossyWhenLocked: true' to accept the loss.");
12938
+ const sealedScopes = this.cryptoEngine.isEnabled() ? await this.lockedScopeIds() : [];
12939
+ if (this.isLocked()) {
12940
+ throw new Error(`exportContent - the stack is locked, so encrypted attributes on ${encrypted.join(", ")} ` +
12941
+ "would be exported as null. Unlock it with 'stack.unlock(documentKey)', or pass " +
12942
+ "'allowLossyWhenLocked: true' to accept the loss.");
12943
+ }
12944
+ if (sealedScopes.length) {
12945
+ throw new Error(`exportContent - scopes ${sealedScopes.join(", ")} are sealed, so their encrypted attributes ` +
12946
+ "would be exported as null. Unlock them with 'stack.unlockScopes(attributeKey)', or pass " +
12947
+ "'allowLossyWhenLocked: true' to accept the loss.");
12948
+ }
12973
12949
  }
12974
12950
  }
12975
12951
  const keep = (doc) => options.includeInactive || doc.active !== false;
@@ -13563,41 +13539,48 @@ class ClientStack extends Stack {
13563
13539
  * @param docs - The documents just written; omit to invalidate everything.
13564
13540
  */
13565
13541
  this.invalidateWriteCaches = (docs) => {
13566
- var _a;
13567
13542
  let classTouched = !docs;
13568
- let policyTouched = !docs;
13543
+ let scopeTouched = !docs;
13569
13544
  for (const doc of docs !== null && docs !== void 0 ? docs : []) {
13570
13545
  if (!doc || typeof doc !== "object")
13571
13546
  continue;
13572
13547
  if (isClassModel(doc))
13573
13548
  classTouched = true;
13574
- else if (doc["~class"] === "~Policy")
13575
- policyTouched = true;
13576
- if (classTouched && policyTouched)
13549
+ else if (doc["~class"] === "~AccessScope")
13550
+ scopeTouched = true;
13551
+ if (classTouched && scopeTouched)
13577
13552
  break;
13578
13553
  }
13579
13554
  if (classTouched) {
13580
13555
  this.classModelCache.clear();
13581
13556
  this.classSnapshotCache.clear();
13582
13557
  }
13583
- if (policyTouched) {
13584
- (_a = this.policyEngine) === null || _a === void 0 ? void 0 : _a.invalidatePolicyCache();
13558
+ if (scopeTouched) {
13559
+ this.accessScopeRegistryDirty = true;
13585
13560
  }
13586
13561
  };
13587
13562
  /**
13588
13563
  * Whether a database-level `limit` returns the same rows as limiting in memory.
13589
13564
  *
13590
- * `findDocuments` filters per document *after* the query - policy checks drop
13591
- * unreadable documents, and a locked crypto engine drops documents whose visible
13592
- * fields are all encrypted. A limit applied before either would under-fill. The
13593
- * query engine asks this before pushing a SQL LIMIT into the fetch.
13565
+ * `findDocuments` drops a document whose visible fields are all sealed - a
13566
+ * locked legacy key, or a scope the keyring cannot open. A limit applied
13567
+ * before that filter would under-fill. So pushdown is allowed exactly when
13568
+ * no row of this class can drop: the class has no encrypted attributes, or
13569
+ * every key that might seal one is held (the legacy key, and every declared
13570
+ * scope - a document may carry any label). The query engine asks this
13571
+ * before pushing a SQL LIMIT into the fetch.
13594
13572
  *
13595
13573
  * @param className - The class being queried.
13596
13574
  */
13597
13575
  this.canApplyQueryLimitEarly = async (className) => {
13598
- if (this.cryptoEngine.isEnabled() && !this.cryptoEngine.getDocumentKey())
13576
+ if (!this.cryptoEngine.isEnabled())
13577
+ return true;
13578
+ const classObj = await this.getClassSnapshot(className).catch(() => null);
13579
+ if (!classObj || !classObj.getEncryptedAttributes().length)
13580
+ return true;
13581
+ if (!this.cryptoEngine.getDocumentKey())
13599
13582
  return false;
13600
- return !(await this.policyEngine.hasPoliciesFor(className));
13583
+ return (await this.lockedScopeIds()).length === 0;
13601
13584
  };
13602
13585
  /** Fields whose sort index exists this session; avoids re-running createIndex. */
13603
13586
  this.sortIndexSession = new Map();
@@ -14268,7 +14251,6 @@ class ClientStack extends Stack {
14268
14251
  // A name-derived id converges: the two writes are the same document.
14269
14252
  const result = await this.createDoc(classModel.name, classOrigin.getName(), classOrigin, classModel);
14270
14253
  fnLogger.info("Added class card", { result });
14271
- await this.ensureDefaultPolicyForClass(result);
14272
14254
  return result;
14273
14255
  }
14274
14256
  catch (e) {
@@ -14448,7 +14430,6 @@ class ClientStack extends Stack {
14448
14430
  // console.log("Doc after merge", { doc_ })
14449
14431
  }
14450
14432
  fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
14451
- await this.policyEngine.ensureWriteAllowed(type, doc_);
14452
14433
  let response = await db.put(doc_);
14453
14434
  // Stamped from the response, not left as the pre-put draft. A caller cannot
14454
14435
  // otherwise tell a document that landed from one that did not - which is
@@ -14552,7 +14533,6 @@ class ClientStack extends Stack {
14552
14533
  fnLogger.info("Doc BEFORE elaboration (i.e. merge)", { doc, params });
14553
14534
  const doc_ = withoutEmptyRev(Object.assign(Object.assign(Object.assign({}, doc), params), { _id: docId, _rev: doc._rev, "~updateTimestamp": new Date().getTime() }));
14554
14535
  fnLogger.info("Doc AFTER elaboration (i.e. merge)", { doc_ });
14555
- await this.policyEngine.ensureWriteAllowed(type, doc_);
14556
14536
  documents.push(doc_);
14557
14537
  if (isNewDoc)
14558
14538
  newDocsIds.push(docId);
@@ -14777,8 +14757,6 @@ class ClientStack extends Stack {
14777
14757
  const doc = await this.db.get(_id);
14778
14758
  if (doc) {
14779
14759
  try {
14780
- const targetClass = doc["~class"];
14781
- await this.policyEngine.ensureWriteAllowed(targetClass, doc);
14782
14760
  await this.db.put(Object.assign(Object.assign({}, doc), { active: false }));
14783
14761
  return true;
14784
14762
  }
@@ -14921,7 +14899,6 @@ class ClientStack extends Stack {
14921
14899
  };
14922
14900
  this.jobEngine = new JobEngine(this);
14923
14901
  this.jobScheduler = new JobScheduler(this);
14924
- this.policyEngine = new PolicyEngine(this);
14925
14902
  this.cryptoEngine = new CryptoEngine(this);
14926
14903
  // Re-created, never carried over: `reset()` re-runs initialize, and a stage
14927
14904
  // surviving a reset would resurrect uncommitted writes (ADR-0039).
@@ -15076,6 +15053,9 @@ class ClientStack extends Stack {
15076
15053
  clearAuthSession() {
15077
15054
  this.authSession = undefined;
15078
15055
  this.cryptoEngine.setDocumentKey(null);
15056
+ // Scope CEKs are session material too: what the attribute key opened
15057
+ // closes with it. Re-adoption goes through `unlockScopes`.
15058
+ this.cryptoEngine.dropScopeKeys();
15079
15059
  }
15080
15060
  /**
15081
15061
  * Whether the stack is operating without its document encryption key.
@@ -15141,6 +15121,153 @@ class ClientStack extends Stack {
15141
15121
  this.dispatchEvent(new CustomEvent("unlocked", { detail: { stackName: this.name } }));
15142
15122
  return this;
15143
15123
  }
15124
+ /**
15125
+ * The access-scope registry, loaded from `~AccessScope` documents and
15126
+ * refreshed whenever one is written (ADR-0045). Raw read: scope docs are
15127
+ * the machinery that DECIDES readability - they cannot sit behind it.
15128
+ */
15129
+ async getAccessScopeRegistry() {
15130
+ if (!this.accessScopeRegistry || this.accessScopeRegistryDirty) {
15131
+ const registry = new Map();
15132
+ const found = await this.db.find({
15133
+ selector: { "~class": "~AccessScope", active: true },
15134
+ limit: 2 ** 31 - 1,
15135
+ }).catch(() => ({ docs: [] }));
15136
+ for (const raw of found.docs) {
15137
+ if (typeof raw.scopeId !== "string" || typeof raw.kid !== "string")
15138
+ continue;
15139
+ let entry = registry.get(raw.scopeId);
15140
+ if (!entry) {
15141
+ entry = { scopeId: raw.scopeId, kids: new Set(), winningVersion: -Infinity, docs: [] };
15142
+ registry.set(raw.scopeId, entry);
15143
+ }
15144
+ entry.kids.add(raw.kid);
15145
+ entry.docs.push(raw);
15146
+ if (typeof raw.version === "number" && raw.version > entry.winningVersion)
15147
+ entry.winningVersion = raw.version;
15148
+ }
15149
+ this.accessScopeRegistry = registry;
15150
+ this.accessScopeRegistryDirty = false;
15151
+ }
15152
+ return this.accessScopeRegistry;
15153
+ }
15154
+ /**
15155
+ * The scope a document's write seals under: its own `~scope` label, else
15156
+ * its class's `defaultScope` (spec 02 §2.2 - the document's value wins).
15157
+ */
15158
+ resolveScopeLabel(doc, classModel) {
15159
+ const own = doc === null || doc === void 0 ? void 0 : doc["~scope"];
15160
+ if (typeof own === "string" && own)
15161
+ return own;
15162
+ const fallback = classModel === null || classModel === void 0 ? void 0 : classModel.defaultScope;
15163
+ return typeof fallback === "string" && fallback ? fallback : undefined;
15164
+ }
15165
+ /** Every key id belonging to a scope, across rotation versions - or null for an unknown scope. */
15166
+ async getAccessScopeKids(scopeId) {
15167
+ var _a, _b;
15168
+ const registry = await this.getAccessScopeRegistry();
15169
+ return (_b = (_a = registry.get(scopeId)) === null || _a === void 0 ? void 0 : _a.kids) !== null && _b !== void 0 ? _b : null;
15170
+ }
15171
+ /** Whether a declared scope's CEK is absent from the keyring - its content is sealed. */
15172
+ isScopeLocked(scopeId) {
15173
+ return !this.cryptoEngine.isScopeWritable(scopeId);
15174
+ }
15175
+ /** Declared scopes whose CEK the keyring lacks. Loaded scopes only - call after open. */
15176
+ async lockedScopeIds() {
15177
+ const registry = await this.getAccessScopeRegistry();
15178
+ return [...registry.keys()].filter(scopeId => !this.cryptoEngine.isScopeWritable(scopeId));
15179
+ }
15180
+ /**
15181
+ * Attempts every declared access scope with the session's attribute key
15182
+ * (ADR-0045): the ABE decryption either yields a scope's CEK - verified
15183
+ * against the scope's canary, then admitted to the keyring - or fails,
15184
+ * and the scope stays locked. There is no gate to ask; this IS the access
15185
+ * decision. Idempotent: a later call with better material unlocks more.
15186
+ * Deferred patches that were waiting on a scope replay after.
15187
+ */
15188
+ async unlockScopes(attributeKey) {
15189
+ if (!this.cryptoEngine.isEnabled()) {
15190
+ throw new Error("Stack was opened with the crypto engine disabled; there are no scopes to unlock.");
15191
+ }
15192
+ if (!attributeKey)
15193
+ throw new Error("unlockScopes requires the session's attribute key.");
15194
+ const fnLogger = logger.child({ method: "unlockScopes" });
15195
+ this.accessScopeRegistryDirty = true;
15196
+ const registry = await this.getAccessScopeRegistry();
15197
+ if (!registry.size)
15198
+ return { unlocked: [], locked: [] };
15199
+ const { decryptCek } = await import('./index2.js');
15200
+ const unlocked = [];
15201
+ const locked = [];
15202
+ for (const entry of registry.values()) {
15203
+ let opened = false;
15204
+ for (const scopeDoc of entry.docs) {
15205
+ if (this.cryptoEngine.getReadableKeyIds().includes(scopeDoc.kid)) {
15206
+ opened = true;
15207
+ continue;
15208
+ }
15209
+ const cekBytes = await decryptCek(attributeKey, scopeDoc.abeWrappedCek).catch(() => null);
15210
+ if (!cekBytes)
15211
+ continue;
15212
+ const cekHex = Array.from(cekBytes, (b) => b.toString(16).padStart(2, "0")).join("");
15213
+ // The scope doc's stated kid and canary are both admission tests: a
15214
+ // corrupted or tampered scope doc is an error here, not garbage later.
15215
+ if (await deriveKeyId(cekHex) !== scopeDoc.kid) {
15216
+ fnLogger.warn("Scope CEK does not match the scope document's kid; refusing admission", { scopeId: entry.scopeId, version: scopeDoc.version });
15217
+ continue;
15218
+ }
15219
+ if (!(await this.cryptoEngine.verifyScopeCek(entry.scopeId, cekHex, scopeDoc.encryptedMarker))) {
15220
+ fnLogger.warn("Scope CEK does not verify against the scope's canary; refusing admission", { scopeId: entry.scopeId, version: scopeDoc.version });
15221
+ continue;
15222
+ }
15223
+ const mode = scopeDoc.version === entry.winningVersion ? "read-write" : "read-only";
15224
+ await this.cryptoEngine.admitScopeKey(entry.scopeId, cekHex, scopeDoc.version, mode);
15225
+ opened = true;
15226
+ this.dispatchEvent(new CustomEvent("scopeUnlocked", { detail: { stackName: this.name, scopeId: entry.scopeId, version: scopeDoc.version, mode } }));
15227
+ }
15228
+ (opened ? unlocked : locked).push(entry.scopeId);
15229
+ }
15230
+ if (unlocked.length && this.deferredPatches.length) {
15231
+ // A patch held back by a sealed scope replays now - the per-scope
15232
+ // half of the ADR-0018/0040 deferral discipline.
15233
+ const deferred = this.deferredPatches;
15234
+ this.deferredPatches = [];
15235
+ await this.applyConsumerPatches(deferred);
15236
+ }
15237
+ fnLogger.info("Scope unlock attempted", { unlocked, locked });
15238
+ return { unlocked, locked };
15239
+ }
15240
+ /**
15241
+ * AUTHORITY-side helper: assembles a complete `~AccessScope` document from
15242
+ * a fresh (or supplied) CEK - ABE-sealing it under the policy, stamping the
15243
+ * kid, minting the per-scope canary. Runs wherever the application controls
15244
+ * (its server, an admin ceremony, tests); it needs the authority PUBLIC key
15245
+ * only, never the master secret. The document is returned, not written -
15246
+ * publishing it (and distributing attribute keys) is the consumer's act.
15247
+ */
15248
+ static async buildAccessScope(input) {
15249
+ var _a, _b;
15250
+ const { wrapCek, normalizePolicy } = await import('./index2.js');
15251
+ const cekHex = (_a = input.cekHex) !== null && _a !== void 0 ? _a : Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0")).join("");
15252
+ if (!/^[0-9a-f]{64}$/.test(cekHex))
15253
+ throw new Error("buildAccessScope needs a 32-byte hex CEK.");
15254
+ const cekBytes = new Uint8Array(cekHex.match(/.{2}/g).map(h => parseInt(h, 16)));
15255
+ const kid = await deriveKeyId(cekHex);
15256
+ const key = await importAesKeyFromHex(cekHex);
15257
+ const marker = await encryptWithAesGcm(JSON.stringify({ nonce: Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(16).padStart(2, "0")).join("") }), key, kid, scopeAad(input.scopeId, kid));
15258
+ const version = (_b = input.version) !== null && _b !== void 0 ? _b : 1;
15259
+ return {
15260
+ _id: `~scope-${input.scopeId}-v${version}`,
15261
+ "~class": "~AccessScope",
15262
+ active: true,
15263
+ scopeId: input.scopeId,
15264
+ policyString: normalizePolicy(input.policyString),
15265
+ abeWrappedCek: await wrapCek(input.pk, input.policyString, cekBytes),
15266
+ kid,
15267
+ version,
15268
+ encryptedMarker: marker,
15269
+ };
15270
+ }
15144
15271
  /**
15145
15272
  * Encrypts bootstrap documents that were seeded before this stack had a key.
15146
15273
  *
@@ -15209,31 +15336,6 @@ class ClientStack extends Stack {
15209
15336
  return 0;
15210
15337
  }
15211
15338
  }
15212
- async ensureDefaultPolicyForClass(targetClass) {
15213
- const fnLogger = logger.child({ method: "ensureDefaultPolicyForClass", targetClass: targetClass._id });
15214
- const existingPolicy = await this.findDocument({
15215
- "~class": { $eq: "~Policy" },
15216
- targetClass: { $elemMatch: { $eq: targetClass._id } }
15217
- });
15218
- if (existingPolicy) {
15219
- return;
15220
- }
15221
- const policyDoc = {
15222
- _id: `Policy-${targetClass._id}`,
15223
- "~class": "~Policy",
15224
- active: true,
15225
- rule: "return session && session.sessionStatus === 'active';",
15226
- description: `Default policy for ${targetClass.name || targetClass._id}`,
15227
- targetClass: [targetClass._id],
15228
- };
15229
- fnLogger.info("Creating default policy", { policyDoc });
15230
- try {
15231
- await this.db.bulkDocs([policyDoc]);
15232
- }
15233
- catch (error) {
15234
- throw new Error(`Failed to create default policy for ${targetClass._id}: ${(error === null || error === void 0 ? void 0 : error.message) || error}`);
15235
- }
15236
- }
15237
15339
  /**
15238
15340
  * Creates and initializes a new ClientStack instance.
15239
15341
  * This is the primary way to instantiate a stack - the constructor is private.
@@ -15276,6 +15378,23 @@ class ClientStack extends Stack {
15276
15378
  && existing.target === p.target
15277
15379
  && existing.active !== false)));
15278
15380
  }
15381
+ // Scope material is attempted AFTER patches: a consumer patch may carry
15382
+ // the very `~AccessScope` docs the key opens, and a patch deferred on a
15383
+ // sealed scope replays inside `unlockScopes`. Adoption discipline is the
15384
+ // consumer's (spec 02 §4): the stack stores nothing of the key.
15385
+ if ((options === null || options === void 0 ? void 0 : options.accessKeys) && stack.cryptoEngine.isEnabled()) {
15386
+ let { attributeKey } = options.accessKeys;
15387
+ if (attributeKey) {
15388
+ await stack.unlockScopes(attributeKey);
15389
+ }
15390
+ const stillLocked = await stack.lockedScopeIds();
15391
+ if (stillLocked.length && options.accessKeys.requestAttributeKey) {
15392
+ const fetched = await options.accessKeys.requestAttributeKey(stillLocked).catch(() => null);
15393
+ if (fetched) {
15394
+ await stack.unlockScopes(fetched);
15395
+ }
15396
+ }
15397
+ }
15279
15398
  if (options === null || options === void 0 ? void 0 : options.credentials) {
15280
15399
  await stack.authenticate(options.credentials);
15281
15400
  }
@@ -15542,7 +15661,7 @@ class ClientStack extends Stack {
15542
15661
  try {
15543
15662
  for (let index = 0; index < patches.length; index++) {
15544
15663
  const patch = patches[index];
15545
- if (this.isLocked() && await this.patchNeedsDocumentKey(patch, stagedClassSchema)) {
15664
+ if (await this.patchBlockedByLock(patch, stagedClassSchema)) {
15546
15665
  await deferFrom(index);
15547
15666
  break;
15548
15667
  }
@@ -15724,6 +15843,47 @@ class ClientStack extends Stack {
15724
15843
  * have when patch N had committed.
15725
15844
  * @returns `true` if any document in it belongs to a class with encrypted attributes.
15726
15845
  */
15846
+ /**
15847
+ * The deferral barrier, generalized per scope (spec 02 §5): a patch is
15848
+ * blocked when the legacy half applies (stack locked and the patch needs
15849
+ * the document key) OR any document it carries writes into a declared
15850
+ * scope whose CEK the keyring lacks - sealing under the wrong key is never
15851
+ * a fallback, so the patch waits for `unlockScopes` exactly as key-needing
15852
+ * patches wait for `unlock`.
15853
+ */
15854
+ async patchBlockedByLock(patch, stagedSchema) {
15855
+ var _a, _b, _c, _d, _e;
15856
+ if (this.isLocked() && await this.patchNeedsDocumentKey(patch, stagedSchema))
15857
+ return true;
15858
+ if (!this.cryptoEngine.isEnabled())
15859
+ return false;
15860
+ const hasEncrypted = (schema) => !!schema && Object.values(schema).some((attribute) => { var _a; return ((_a = attribute === null || attribute === void 0 ? void 0 : attribute.config) === null || _a === void 0 ? void 0 : _a.encrypted) === true; });
15861
+ const classModelsInPatch = new Map();
15862
+ for (const doc of (_a = patch.docs) !== null && _a !== void 0 ? _a : []) {
15863
+ if (isClassModel(doc))
15864
+ classModelsInPatch.set((_b = doc.name) !== null && _b !== void 0 ? _b : doc._id, doc);
15865
+ }
15866
+ for (const doc of (_c = patch.docs) !== null && _c !== void 0 ? _c : []) {
15867
+ if (isClassModel(doc) || isRelation(doc))
15868
+ continue;
15869
+ const className = doc["~class"];
15870
+ if (typeof className !== "string" || !className)
15871
+ continue;
15872
+ const inPatch = classModelsInPatch.get(className);
15873
+ const stored = inPatch ? null : await this.getClassModel(className).catch(() => null);
15874
+ const label = this.resolveScopeLabel(doc, (inPatch !== null && inPatch !== void 0 ? inPatch : stored));
15875
+ if (!label)
15876
+ continue;
15877
+ const schema = (_e = (_d = inPatch === null || inPatch === void 0 ? void 0 : inPatch.schema) !== null && _d !== void 0 ? _d : stagedSchema === null || stagedSchema === void 0 ? void 0 : stagedSchema(className)) !== null && _e !== void 0 ? _e : stored === null || stored === void 0 ? void 0 : stored.schema;
15878
+ if (hasEncrypted(schema) && !this.cryptoEngine.isScopeWritable(label)) {
15879
+ // Only a DECLARED scope defers - an unknown label is a patch
15880
+ // fault the chain should refuse loudly, not wait on forever.
15881
+ if ((await this.getAccessScopeRegistry()).has(label))
15882
+ return true;
15883
+ }
15884
+ }
15885
+ return false;
15886
+ }
15727
15887
  async patchNeedsDocumentKey(patch, stagedSchema) {
15728
15888
  var _a;
15729
15889
  // A one-shot job's touch-set cannot be inspected, so the author answers the
@@ -16347,11 +16507,6 @@ class ClientStack extends Stack {
16347
16507
  return classesInResult.get(className);
16348
16508
  };
16349
16509
  for (const doc of docs) {
16350
- const canRead = await this.policyEngine.isReadableDocument(doc);
16351
- if (!canRead) {
16352
- logger.info("processFoundDocuments - document is not readable by policy", { docId: doc._id, docClass: doc["~class"] });
16353
- continue;
16354
- }
16355
16510
  const encryptedKeys = this.cryptoEngine.identifyEncryptedKeys(doc);
16356
16511
  const classObj = encryptedKeys.length || (fields && fields.length)
16357
16512
  ? await classFor(doc["~class"])
@@ -16372,14 +16527,18 @@ class ClientStack extends Stack {
16372
16527
  return doc;
16373
16528
  }
16374
16529
  const clone = Object.assign({}, doc);
16375
- const hasDocumentKey = Boolean(this.cryptoEngine.getDocumentKey());
16376
- if (hasDocumentKey && encryptedKeys.length) {
16530
+ // Per payload, not per stack (spec 02 §5): the keyring opens what it
16531
+ // can - legacy key, retired keys, unlocked scope CEKs - and whatever
16532
+ // stays sealed (a locked scope, a missing legacy key, a payload whose
16533
+ // label fails its AAD) reads as `null`, the locked-read convention
16534
+ // applied at the granularity the keyring actually has.
16535
+ let sealedCount = 0;
16536
+ if (encryptedKeys.length) {
16377
16537
  await this.cryptoEngine.decryptDocument(clone, classObj, encryptedKeys);
16378
- }
16379
- else if (encryptedKeys.length) {
16380
16538
  for (const key of encryptedKeys) {
16381
- if (clone[key] !== undefined) {
16539
+ if (isEncryptedPayload(clone[key])) {
16382
16540
  clone[key] = null;
16541
+ sealedCount++;
16383
16542
  }
16384
16543
  }
16385
16544
  }
@@ -16393,7 +16552,9 @@ class ClientStack extends Stack {
16393
16552
  }
16394
16553
  return clone[key] !== undefined;
16395
16554
  });
16396
- if (!hasDocumentKey && encryptedKeySet.size) {
16555
+ if (sealedCount > 0) {
16556
+ // A document whose every visible field stayed sealed is hidden -
16557
+ // there is nothing of it this keyring can show.
16397
16558
  const nonEncryptedVisible = visibleKeys.filter((key) => !encryptedKeySet.has(key));
16398
16559
  if (!nonEncryptedVisible.length) {
16399
16560
  return null;
@@ -17331,4 +17492,4 @@ class DocStack extends EventTarget {
17331
17492
  }
17332
17493
  }
17333
17494
 
17334
- export { Attribute, CONTENT_EXPORT_FORMAT, Class, ClientStack, DATA_MODEL_CLASSES, DocStack, DocStackSyncHandle, Domain, INTERNAL_DOC_CLASSES, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, JOB_SCHEDULE_DOC_ID, JobEngine, JobScheduler, META_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, SYNC_META_DOC_ID, SYSTEM_SEEDED_DOC_IDS, StackLockedError, StackSyncHandle, StackWriteGuardError, SyncSchemaMismatchError, TransactionConflictError, TransactionDb, TransactionEngine, TransactionHandle, TransactionStateError, TransactionUnsupportedDocError, TransactionValidationError, TransactionsDisabledError, Trigger, classTenants, collectQueryClasses, createClassFilter, createReplicationFilter, DocStack as default, deriveKeyId, deriveTenantScope, describeFilter, hasClassRules, isContentClassName, isContentDocument, isContentRelation, isEncryptedPayload, isInternalDoc, nextOccurrence, parseSchedule, publishSchemaVersion, readRemoteConsumerSchemaVersion, readRemoteSchemaVersion, resolveInternalClasses, withFilterIdentity };
17495
+ export { Attribute, CONTENT_EXPORT_FORMAT, Class, ClientStack, DATA_MODEL_CLASSES, DocStack, DocStackSyncHandle, Domain, INTERNAL_DOC_CLASSES, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, JOB_SCHEDULE_DOC_ID, JobEngine, JobScheduler, META_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, SYNC_META_DOC_ID, SYSTEM_SEEDED_DOC_IDS, StackLockedError, StackScopeMismatchError, StackSyncHandle, StackWriteGuardError, SyncSchemaMismatchError, TransactionConflictError, TransactionDb, TransactionEngine, TransactionHandle, TransactionStateError, TransactionUnsupportedDocError, TransactionValidationError, TransactionsDisabledError, Trigger, classTenants, collectQueryClasses, createClassFilter, createReplicationFilter, DocStack as default, deriveKeyId, deriveTenantScope, describeFilter, hasClassRules, isContentClassName, isContentDocument, isContentRelation, isEncryptedPayload, isInternalDoc, nextOccurrence, parseSchedule, publishSchemaVersion, readRemoteConsumerSchemaVersion, readRemoteSchemaVersion, resolveInternalClasses, withFilterIdentity };