@noy-db/hub 0.6.0-pre.8 → 0.6.0-pre.9

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,6 +1,6 @@
1
1
  import {
2
2
  PERIODS_COLLECTION
3
- } from "./chunk-JXIDGFKS.js";
3
+ } from "./chunk-HPLWFNG4.js";
4
4
  import {
5
5
  canonicalJson,
6
6
  sha256Hex
@@ -14,6 +14,35 @@ import {
14
14
  } from "./chunk-HNGSV2YC.js";
15
15
 
16
16
  // src/with-audit/periods/periods.ts
17
+ function resolveReopenState(events) {
18
+ let lastReopen;
19
+ let reclosedAfter;
20
+ let reopenCount = 0;
21
+ for (const e of events) {
22
+ if (e.op === "reopen") {
23
+ lastReopen = e;
24
+ reclosedAfter = void 0;
25
+ reopenCount++;
26
+ } else if (lastReopen !== void 0) {
27
+ reclosedAfter = e.at;
28
+ }
29
+ }
30
+ if (!lastReopen) return { reopenCount };
31
+ return {
32
+ reopenedAt: lastReopen.at,
33
+ reopenedBy: lastReopen.by,
34
+ ...lastReopen.until !== void 0 && { reopenedUntil: lastReopen.until },
35
+ ...lastReopen.reason !== void 0 && { reopenReason: lastReopen.reason },
36
+ ...reclosedAfter !== void 0 && { reclosedAt: reclosedAfter },
37
+ reopenCount
38
+ };
39
+ }
40
+ function isEffectivelyReopened(period, nowIso) {
41
+ if (period.reopenedAt === void 0) return false;
42
+ if (period.reclosedAt !== void 0 && period.reclosedAt >= period.reopenedAt) return false;
43
+ if (period.reopenedUntil !== void 0 && nowIso > period.reopenedUntil) return false;
44
+ return true;
45
+ }
17
46
  function resolvePeriodKey(name, partition) {
18
47
  if (!partition || partition.length === 0) return name;
19
48
  const parts = partition.map((p) => {
@@ -59,8 +88,10 @@ function assertTsWritable(existing, incomingRecord, closedPeriods, scope) {
59
88
  const existingRecord = existing?.record ?? null;
60
89
  const existingPartition = partitionOf(existingRecord);
61
90
  const incomingPartition = partitionOf(incomingRecord);
91
+ const now = (/* @__PURE__ */ new Date()).toISOString();
62
92
  for (const p of closedPeriods) {
63
93
  if (p.kind !== "closed") continue;
94
+ if (isEffectivelyReopened(p, now)) continue;
64
95
  if (p.dateField) {
65
96
  const checkRecord = (label, r, recordPartition) => {
66
97
  if (!r) return;
@@ -155,6 +186,8 @@ var NO_PERIODS = {
155
186
 
156
187
  export {
157
188
  NO_PERIODS,
189
+ resolveReopenState,
190
+ isEffectivelyReopened,
158
191
  resolvePeriodKey,
159
192
  samePartition,
160
193
  loadPeriods,
@@ -165,4 +198,4 @@ export {
165
198
  purgeMarkersOn,
166
199
  withPeriods
167
200
  };
168
- //# sourceMappingURL=chunk-PXCBVDFY.js.map
201
+ //# sourceMappingURL=chunk-5O5HRRTV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-audit/periods/periods.ts","../src/with-audit/periods/active.ts","../src/with-audit/periods/strategy.ts"],"sourcesContent":["/**\n * Accounting-period closure + opening.\n *\n * A closed period seals every record whose envelope `_ts` is at or\n * before the period's `endDate`: further writes (`put` / `delete`)\n * against such records throw {@link PeriodClosedError}. The period\n * itself is stored as a record in the reserved `_periods` collection\n * and written through the normal ledger-instrumented path, so every\n * closure appends a tamper-evident entry to the vault's hash chain.\n *\n * ## Closure model\n *\n * ```\n * vault.closePeriod({ name: 'FY2026-Q1', endDate: '2026-03-31' })\n * └─► PeriodRecord written to _periods/<name>\n * ├─ priorPeriodName / priorPeriodHash — chain to last close\n * ├─ closedAt / closedBy — provenance\n * └─ normal ledger append fires (LedgerStore.append)\n * ```\n *\n * Enforcement (`assertTsWritable`) is vault-local: the Vault caches\n * the list of closed periods on first read and consults that cache in\n * the `Collection.put` / `.delete` path via the `periodGuard` hook.\n *\n * ## Opening model\n *\n * ```\n * vault.openPeriod({\n * name: 'FY2026-Q2',\n * startDate: '2026-04-01',\n * fromPeriod: 'FY2026-Q1',\n * carryForward: async (priorView) => Record<string, Record<string, unknown>>,\n * })\n * ```\n *\n * `carryForward` receives a read-only `VaultInstant` anchored at the\n * prior period's `endDate` (built via `vault.at(endDate)`) so the\n * callback can compute closing aggregates from the sealed state. The\n * returned `{ [collectionName]: { [id]: record } }` map is written\n * before the new `PeriodRecord` lands — opening balances materialise\n * as normal records with fresh timestamps that fall outside every\n * closed period.\n *\n * ## Freeze\n *\n * ```\n * vault.freezePeriod('FY2026-Q1')\n * └─► physically purges delete markers whose write-time falls inside\n * the closed period's window (via the #589 `_purgeDeleteMarkers`\n * seam), then records the fact:\n * ├─ PeriodFreezeRecord written to _period_freezes/<name>\n * └─ normal ledger append fires (LedgerStore.append)\n * ```\n *\n * The chained `_periods/<name>` record is never mutated — `frozenAt` /\n * `frozenBy` / `purgedMarkerCount` are merged onto the returned\n * `PeriodRecord` at read time from the companion, so a tamper with the\n * freeze can never break the inter-period hash chain. Freezing is\n * terminal (a closed period, once frozen, stays frozen) and idempotent\n * (a second call is a no-op that returns the same merged record without\n * re-purging or re-appending a ledger entry). Freeze does NOT purge\n * forget-tombstones (GDPR crypto-shred erasure evidence), `_history`\n * versions, or live records — the delete-markers-only seam leaves all\n * three untouched by construction.\n *\n * Freeze purges the LOCAL adapter only. On a synced vault, markers already\n * pushed to sync targets survive there, and a later pull re-imports them\n * (benign — they still read deleted, but the space isn't reclaimed). A\n * re-imported marker keeps its original `_ts` (inside the already-frozen\n * period's window), so — like any late-booked delete — it is reclaimed by\n * the NEXT period's freeze, whose window covers it; freeze stays terminal\n * and does NOT re-purge an already-frozen period (#611). Sweeping the sync\n * targets themselves is a cross-target-purge concern deferred to the\n * cold-archival spec. Purging re-opens the #589 resurrection window for a\n * peer offline since before the cutoff, which is why the closed period is\n * the operator-asserted safe-point that gates the call.\n *\n * A period whose purge window has not fully elapsed cannot be frozen —\n * `freezePeriod` throws rather than purge markers for deletes that may not\n * have converged yet (#610).\n *\n * ## Archive\n *\n * ```\n * vault.archivePeriod('FY2026-Q1')\n * └─► relocates the closed period's in-window records (those with\n * `_ts < periodExclusiveUpperBound(endDate)`) from the hot store to\n * the configured cold tier (routeStore's `cold` route), then records:\n * ├─ PeriodArchiveRecord written to _period_archives/<name>\n * └─ a ledger entry attributed to _period_archives\n * ```\n *\n * Archival is NON-DESTRUCTIVE: routeStore reads fall through to the cold\n * tier on a hot miss, so an archived record still reads normally. It is\n * therefore gated only on `closed` (not `frozen`) — it does not re-open the\n * #589 resurrection window and needs no convergence safe-point. Freeze\n * (purge markers) and archive (relocate records) are independent and compose\n * in either order. Like freeze, archival keeps the chained `_periods/<name>`\n * record byte-immutable (state lives in the companion) and is idempotent.\n *\n * Bounds by write-time `_ts`, NOT business date: the store tier sees only\n * encrypted envelopes. A record with an in-period business date but a later\n * `_ts` (late-booked) archives at the NEXT period's archive — the same rule\n * freeze uses for late-booked delete markers. Requires a `routeStore` with a\n * cold route (`age: { cold }`); throws otherwise.\n *\n * Read cost: with `withLazy()` (per-id reads) archived records are truly\n * cold — fetched from cold only on access. In the default hydrated mode,\n * `loadAll` merges the cold store, so archived records still load into RAM\n * on vault open (hot-tier STORAGE is reclaimed; RAM is not). Summaries\n * (`_`-prefixed) always stay hot.\n *\n * ## Target-purge\n *\n * ```\n * vault.purgePeriodTargets('FY2026-Q1')\n * └─► sweeps delete markers (`_ts < periodExclusiveUpperBound(endDate)`) off\n * the vault's PUSH-ONLY sync targets (backup/archive roles), then records:\n * ├─ PeriodTargetPurgeRecord written to _period_target_purges/<name>\n * └─ a ledger entry attributed to _period_target_purges\n * ```\n *\n * Extends freeze's local marker purge to the vault's own remote sinks.\n * `sync-peer` (bidirectional) targets are SKIPPED: purging a marker there\n * re-opens the #589 resurrection window for a client offline before the\n * cutoff, an assertion no single vault can verify. Backup/archive targets are\n * push-only — never pulled from into convergence — so sweeping their markers\n * is safe. Requires the period be frozen first (closed → frozen →\n * target-purged) so the local safe-point is already established. Idempotent\n * once run; a vault with no push-only targets writes no companion and is\n * re-runnable (so a target added later is still swept). Single-vault only —\n * fleet-wide purge across sovereign vaults is klum's concern over\n * `@noy-db/hub/cargo`.\n *\n * ## Not covered\n *\n * - Partial re-opening of a closed period. If an auditor needs to\n * make a correction inside a sealed period, the sanctioned path is\n * a compensating entry in the NEW period, not an unlock of the\n * old one.\n * - Automatic period rollover. `closePeriod` / `openPeriod` are\n * deliberately explicit operator calls so the caller decides when\n * the boundary lands.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope } from '../../kernel/types.js'\nimport type { LedgerStore } from '../../with-commit/history/ledger/index.js'\nimport { sha256Hex, canonicalJson } from '../../with-commit/history/ledger/index.js'\nimport { isDeleteMarker } from '../../kernel/enclave/index.js'\nimport { PeriodClosedError, ValidationError } from '../../kernel/errors.js'\n\n// The reserved collection names + `periodExclusiveUpperBound` moved to the\n// dependency-light `window.ts` so the sync engine's period-scoped pull (#807)\n// can import them without this module's ledger imports; re-exported here so\n// every existing import path keeps working.\nexport {\n PERIODS_COLLECTION,\n PERIOD_FREEZES_COLLECTION,\n PERIOD_ARCHIVES_COLLECTION,\n PERIOD_TARGET_PURGES_COLLECTION,\n PERIOD_REOPENS_COLLECTION,\n periodExclusiveUpperBound,\n} from './window.js'\nimport { PERIODS_COLLECTION } from './window.js'\n\n/**\n * Companion record recording that a closed period was frozen (its delete\n * markers physically purged). Stored in {@link PERIOD_FREEZES_COLLECTION},\n * keyed by period name — kept OFF the hash-chained `_periods/<name>` record so\n * freeze never alters the inter-period chain.\n */\nexport interface PeriodFreezeRecord {\n readonly period: string\n readonly frozenAt: string\n readonly frozenBy: string\n readonly purgedMarkerCount: number\n}\n\n/**\n * Companion record noting that a closed period was cold-archived (its\n * in-window records physically relocated hot → cold). Stored in\n * {@link PERIOD_ARCHIVES_COLLECTION}, keyed by period name — kept OFF the\n * hash-chained `_periods/<name>` record so archive never alters the chain.\n */\nexport interface PeriodArchiveRecord {\n readonly period: string\n readonly archivedAt: string\n readonly archivedBy: string\n readonly archivedRecordCount: number\n}\n\n/** Per-target count of delete markers purged off one push-only sync target (#615). */\nexport interface TargetPurgeCount {\n readonly label?: string\n readonly role: 'backup' | 'archive'\n readonly purgedCount: number\n}\n\n/**\n * One entry in a period's append-only reopen/reclose log (#1022).\n *\n * Real accounting close is a three-state lifecycle — open / closed / reopened\n * — not a one-way door. A month gets closed, a missing invoice arrives or a\n * filing is rejected, the month is reopened, corrected, and reclosed. The audit\n * value is not the ability to write again; it is the chain being able to say\n * *closed at T1, reopened at T2 by U, reclosed at T3*.\n */\nexport interface PeriodReopenEvent {\n readonly op: 'reopen' | 'reclose'\n /** ISO timestamp the event was recorded. */\n readonly at: string\n /** userId of the keyring that performed it. */\n readonly by: string\n /**\n * `reopen` only — ISO instant after which the period re-seals on its own,\n * with nobody acting. Absent means the window stays open until an explicit\n * `reclosePeriod`.\n */\n readonly until?: string\n /** Free-text justification, carried verbatim into the audit trail. */\n readonly reason?: string\n}\n\n/**\n * Companion holding a period's reopen/reclose history (#1022). Stored in\n * {@link PERIOD_REOPENS_COLLECTION}, keyed by the period's storage key — kept\n * OFF the hash-chained `_periods/<name>` record for the same reason freeze and\n * archive are: reopening must never rewrite the close, or the chain that proves\n * the close happened is the very thing the reopen destroys.\n *\n * `events` is APPEND-ONLY. Where the other companions are single-shot and\n * idempotent, this one accumulates, because the cycle repeats.\n */\nexport interface PeriodReopenRecord {\n readonly period: string\n readonly partition?: PeriodPartition\n readonly events: readonly PeriodReopenEvent[]\n}\n\n/**\n * Collapse an append-only reopen log into the return-only fields merged onto a\n * {@link PeriodRecord} on read.\n *\n * Expiry is deliberately NOT resolved here: `reopenedUntil` is carried through\n * verbatim and compared against the clock at write-guard time, so a bounded\n * window re-seals on its own without anything having to run.\n *\n * @internal\n */\nexport function resolveReopenState(events: readonly PeriodReopenEvent[]): {\n reopenedAt?: string\n reopenedBy?: string\n reopenedUntil?: string\n reopenReason?: string\n reclosedAt?: string\n reopenCount: number\n} {\n let lastReopen: PeriodReopenEvent | undefined\n let reclosedAfter: string | undefined\n let reopenCount = 0\n for (const e of events) {\n if (e.op === 'reopen') {\n lastReopen = e\n reclosedAfter = undefined\n reopenCount++\n } else if (lastReopen !== undefined) {\n reclosedAfter = e.at\n }\n }\n if (!lastReopen) return { reopenCount }\n return {\n reopenedAt: lastReopen.at,\n reopenedBy: lastReopen.by,\n ...(lastReopen.until !== undefined && { reopenedUntil: lastReopen.until }),\n ...(lastReopen.reason !== undefined && { reopenReason: lastReopen.reason }),\n ...(reclosedAfter !== undefined && { reclosedAt: reclosedAfter }),\n reopenCount,\n }\n}\n\n/**\n * Is this period writable right now on account of a reopen? (#1022)\n *\n * Three ways to be sealed again: never reopened, explicitly reclosed after the\n * last reopen, or a bounded window that has elapsed. The clock is read by the\n * caller and passed in, so the guard and any diagnostic agree on one instant.\n *\n * @internal\n */\nexport function isEffectivelyReopened(period: PeriodRecord, nowIso: string): boolean {\n if (period.reopenedAt === undefined) return false\n if (period.reclosedAt !== undefined && period.reclosedAt >= period.reopenedAt) return false\n if (period.reopenedUntil !== undefined && nowIso > period.reopenedUntil) return false\n return true\n}\n\n/**\n * Companion record noting that a closed+frozen period's delete markers were\n * swept off the vault's push-only sync targets (#615). Stored in\n * {@link PERIOD_TARGET_PURGES_COLLECTION}, keyed by period name — kept OFF the\n * hash-chained `_periods/<name>` record so target-purge never alters the chain.\n */\nexport interface PeriodTargetPurgeRecord {\n readonly period: string\n readonly purgedAt: string\n readonly purgedBy: string\n readonly targets: readonly TargetPurgeCount[]\n}\n\n/**\n * Scope tuple for a period timeline (#1005).\n *\n * Identical in shape and semantics to `SequenceOptions.partition`: a\n * partitioned timeline is always disjoint from any unpartitioned one, and from\n * every other tuple. `['acme', 'vat']` and `['acme', 'wht']` are two\n * independent close calendars for the same subject — which is the whole point,\n * since sub-ledgers for one legal entity and one month routinely close on\n * different statutory deadlines.\n */\nexport type PeriodPartition = readonly (string | number)[]\n\n/**\n * Resolve the `_periods` storage key for a (name, partition) pair.\n *\n * Deliberately the same encoding as `resolveSequenceKey`: `name` verbatim when\n * unpartitioned, else `${name}\\x00${parts}` with each component\n * `encodeURIComponent`d and `'/'`-joined. The null-byte separator cannot occur\n * in a period name, so a partitioned key never collides with an unpartitioned\n * one; URI-encoding keeps `['a/b']` distinct from `['a','b']`.\n *\n * @throws {ValidationError} on an empty component or a non-finite number.\n * @internal\n */\nexport function resolvePeriodKey(name: string, partition?: PeriodPartition): string {\n if (!partition || partition.length === 0) return name\n const parts = partition.map((p) => {\n if (typeof p === 'number' && !Number.isFinite(p)) {\n throw new ValidationError(`period partition component must be a finite number, got ${p}`)\n }\n const s = String(p)\n if (s === '') {\n throw new ValidationError('period partition component must not be empty')\n }\n return encodeURIComponent(s)\n })\n return `${name}\\x00${parts.join('/')}`\n}\n\n/**\n * Do two partitions denote the same timeline? Absent and empty both mean \"the\n * unpartitioned timeline\", so they compare equal.\n *\n * @internal\n */\nexport function samePartition(a?: PeriodPartition, b?: PeriodPartition): boolean {\n const x = a ?? []\n const y = b ?? []\n if (x.length !== y.length) return false\n return x.every((v, i) => String(v) === String(y[i]))\n}\n\n/**\n * Resolves a record to the timeline that governs it. Supplied by\n * `withPeriods({ subjects })`; returns `undefined` for any collection with no\n * mapping, which is what keeps an unconfigured vault on the single vault-wide\n * timeline it has always had.\n *\n * @internal\n */\nexport type PartitionResolver = (\n collection: string,\n record: Record<string, unknown>,\n) => PeriodPartition | undefined\n\n/**\n * Stored record for one closed or opened accounting period. One entry\n * per period, keyed by `name` in the reserved `_periods` collection.\n *\n * The hash chain between periods is computed at read time by\n * `loadPeriods()` — each record carries the name + hash of its\n * predecessor so a tamper with any period's record breaks the chain\n * into the next one, the same way the ledger's `prevHash` works.\n */\nexport interface PeriodRecord {\n /**\n * Human-readable name (e.g., `'FY2026-Q1'`). Unique per PARTITION — two\n * timelines may each carry a `'2026-06'`, which is the normal case when one\n * vault serves several subjects (#1005). Unique per vault when unpartitioned.\n */\n readonly name: string\n /**\n * The timeline this period belongs to. Absent = the vault-wide timeline.\n * Two periods with the same `name` and different `partition` are unrelated:\n * separate hash chains, separate close state, and the write guard applies\n * each only to records that resolve to its own tuple.\n */\n readonly partition?: PeriodPartition\n /**\n * Role discriminator. A period is `'closed'` from the moment its\n * `closedAt` is recorded; `'opened'` marks a period whose opening\n * entries have been carried forward via {@link openPeriod}. Many\n * workflows will produce one opened period per closed period (the\n * opened one is the SUCCESSOR — its `startDate` equals the prior\n * `endDate + 1 day`).\n */\n readonly kind: 'closed' | 'opened'\n /** ISO date — inclusive upper bound for records belonging to this period. */\n readonly endDate: string\n /** ISO date — lower bound (present on opened periods only). */\n readonly startDate?: string\n /**\n * Record field carrying the business date (e.g. `'date'` on an\n * invoice, `'paidAt'` on a payment). The guard compares\n * `record[dateField]` against `endDate` — NOT the envelope `_ts`.\n * Accounting entries booked late (business date `2026-01-15`,\n * write-time `2026-04-22`) still get sealed when Q1 closes at\n * `2026-03-31` because the comparison uses the business date.\n *\n * Optional for backwards compat. When absent, the guard falls back\n * to envelope `_ts` — that's a write-time seal, appropriate for\n * content that doesn't carry a logical business date (e.g. system\n * settings) but almost never right for accounting ledgers.\n */\n readonly dateField?: string\n /** ISO timestamp recorded at `closePeriod()` / `openPeriod()` call time. */\n readonly closedAt: string\n /** userId of the keyring that invoked the close/open. */\n readonly closedBy: string\n /** Name of the prior period this one chains to, if any. */\n readonly priorPeriodName?: string\n /** sha256(canonicalJson(priorPeriod)) — empty for the first period. */\n readonly priorPeriodHash: string\n /**\n * Opened periods only — the names of the collections whose\n * carry-forward aggregates were written by {@link openPeriod}.\n * Recorded for auditability so a future `verifyPeriodChain()` can\n * cross-check the opening balances against the closing snapshot.\n */\n readonly openingCollections?: readonly string[]\n /** #604 return-only — merged from the `_period_freezes/<name>` companion on\n * read; NEVER written into the stored `_periods/<name>` record (would break\n * the hash chain). Absent = not yet frozen. */\n readonly frozenAt?: string\n readonly frozenBy?: string\n readonly purgedMarkerCount?: number\n /** #613 return-only — merged from the `_period_archives/<name>` companion on\n * read; NEVER written into the stored `_periods/<name>` record. Absent = not\n * yet archived. */\n readonly archivedAt?: string\n readonly archivedBy?: string\n readonly archivedRecordCount?: number\n /** #615 return-only — merged from the `_period_target_purges/<name>` companion\n * on read; NEVER written into the stored `_periods/<name>` record. Absent =\n * target-purge not yet run (or the vault has no push-only targets). */\n readonly targetsPurgedAt?: string\n readonly targetsPurgedBy?: string\n readonly targetsPurged?: readonly TargetPurgeCount[]\n /** #1022 return-only — collapsed from the `_period_reopens/<key>` append-only\n * log on read; NEVER written into the stored `_periods/<name>` record, so a\n * reopen cannot disturb the inter-period hash chain. Absent = never reopened.\n * `reclosedAt` present (and >= `reopenedAt`) means the window was closed\n * again explicitly; `reopenedUntil` in the past means it lapsed on its own.\n * Use {@link isEffectivelyReopened} rather than reading these directly. */\n readonly reopenedAt?: string\n readonly reopenedBy?: string\n readonly reopenedUntil?: string\n readonly reopenReason?: string\n readonly reclosedAt?: string\n /** How many times this period has been reopened, ever. */\n readonly reopenCount?: number\n}\n\n/** Options for `vault.closePeriod()`. */\nexport interface ClosePeriodOptions {\n /** Human-readable name. Must not collide with an existing period. */\n readonly name: string\n /**\n * Inclusive upper cutoff. A record is sealed when its\n * `record[dateField]` (or, if absent, the envelope `_ts`) is at or\n * before this ISO timestamp.\n */\n readonly endDate: string\n /**\n * Record field carrying the business date used for period\n * membership. Recommended for accounting workflows — e.g. an\n * invoice booked late (write-time after close) is still sealed\n * when its `invoice.date` falls inside the closed period.\n *\n * Omit to use envelope `_ts` (write-time seal). This fallback\n * rarely matches real-world accounting semantics; prefer passing\n * an explicit `dateField`.\n */\n readonly dateField?: string\n /**\n * Close only this timeline (#1005). Omit for the vault-wide timeline.\n *\n * ```ts\n * vault.closePeriod({\n * name: '2026-06', endDate: '2026-06-30', dateField: 'issuedAt',\n * partition: [clientId, 'vat'],\n * })\n * ```\n *\n * Which records the resulting seal applies to is decided by the\n * `subjects` map passed to `withPeriods()` — without one, no record ever\n * resolves to a partition and a partitioned close seals nothing.\n */\n readonly partition?: PeriodPartition\n}\n\n/** Options for `vault.openPeriod()`. */\nexport interface OpenPeriodOptions<TCollections = Record<string, Record<string, unknown>>> {\n /** Human-readable name for the new period. Must be unique. */\n readonly name: string\n /**\n * The timeline to open in. Must match the partition of `fromPeriod` — a\n * period cannot chain across timelines, since each carries its own hash\n * chain (#1005).\n */\n readonly partition?: PeriodPartition\n /** ISO lower bound of the new period (usually prior `endDate + 1 day`). */\n readonly startDate: string\n /**\n * Name of the prior CLOSED period this one chains from. The prior\n * period's record is verified to exist and to be `kind: 'closed'`;\n * its `endDate` is made available to the `carryForward` callback.\n */\n readonly fromPeriod: string\n /**\n * Receives a read-only facade over the vault's CURRENT state,\n * plus the prior period's `endDate`. Accounting semantics: after\n * a period closes, records with `record[dateField] <= endDate`\n * are frozen — current state equals closing state, so a caller\n * can compute closing balances by querying the live collection\n * with a `where('date', '<=', priorEndDate)` filter.\n *\n * Returns opening-balance records keyed by collection name.\n * Example:\n *\n * ```ts\n * carryForward: async (ctx) => {\n * const closing = await ctx.collection<Journal>('journal')\n * .query().where('date', '<=', ctx.priorEndDate).toArray()\n * const opening: Record<string, Journal> = {}\n * for (const entry of closing) {\n * opening[`OB-${entry.id}`] = { ...entry, date: '2026-04-01' }\n * }\n * return { journal: opening }\n * }\n * ```\n */\n readonly carryForward: (\n ctx: CarryForwardContext,\n ) => Promise<TCollections> | TCollections\n}\n\n/**\n * Context passed to `OpenPeriodOptions.carryForward`. Exposes a\n * read-only subset of the live vault (`collection(name).get/list`)\n * plus the prior period's `endDate` so business-date filters can\n * be built by the caller.\n *\n * Writes go via the return value, not via the facade — the\n * `collection()` here is deliberately restricted to reads.\n */\nexport interface CarryForwardContext {\n /** The prior period's `endDate` — the boundary of the closing snapshot. */\n readonly priorEndDate: string\n /** Read-only collection facade over current vault state. */\n collection<T = unknown>(name: string): ReadOnlyCollection<T>\n}\n\n/** Minimum read surface exposed to `carryForward`. */\nexport interface ReadOnlyCollection<T> {\n get(id: string): Promise<T | null>\n list(): Promise<T[]>\n}\n\n/**\n * Load every period record currently stored on the adapter.\n * Decrypting is the caller's responsibility (we return plain records\n * so the vault can use its own `_periods` DEK).\n *\n * @internal — called by Vault methods that need the closed-period\n * cache. Not part of the public API surface.\n */\nexport async function loadPeriods(\n adapter: NoydbStore,\n vault: string,\n decrypt: (envelope: EncryptedEnvelope) => Promise<PeriodRecord>,\n): Promise<PeriodRecord[]> {\n const ids = await adapter.list(vault, PERIODS_COLLECTION)\n const records: PeriodRecord[] = []\n for (const id of ids) {\n const env = await adapter.get(vault, PERIODS_COLLECTION, id)\n if (env) records.push(await decrypt(env))\n }\n // Stable order by closedAt so chain verification is reproducible.\n records.sort((a, b) => a.closedAt.localeCompare(b.closedAt))\n return records\n}\n\n/**\n * Given the current ordered period list, pick the last entry that\n * belongs to the hash chain — used as the `priorPeriodHash` anchor\n * for the next closure/opening.\n *\n * @internal\n */\nexport async function chainAnchor(\n records: readonly PeriodRecord[],\n partition?: PeriodPartition,\n): Promise<{ priorPeriodName?: string; priorPeriodHash: string }> {\n // #1005 — each timeline carries its OWN chain. Anchoring a partitioned close\n // to whatever happened to be written last vault-wide would interleave\n // unrelated subjects into one chain, so verifying client A's June would\n // depend on client B never having closed in between.\n const inTimeline = records.filter((p) => samePartition(p.partition, partition))\n const last = inTimeline[inTimeline.length - 1]\n if (!last) return { priorPeriodHash: '' }\n const hash = await sha256Hex(canonicalJson(last as unknown as Record<string, unknown>))\n return { priorPeriodName: last.name, priorPeriodHash: hash }\n}\n\n/**\n * Throw `PeriodClosedError` if the record being touched falls within\n * any closed period.\n *\n * Three signals, evaluated per period:\n *\n * 1. If the period declares a `dateField`, the guard reads\n * `record[dateField]` on BOTH the existing (prior) record AND the\n * incoming (new) record. Either comparing `<= endDate` triggers\n * the error — callers cannot slide a record into a closed period\n * by editing its date field.\n * 2. If the period has no `dateField`, the guard falls back to the\n * envelope `_ts` of the existing record. Fresh inserts (no\n * existing envelope) pass.\n * 3. For a delete, only the existing side is checked.\n *\n * @internal\n */\nexport function assertTsWritable(\n existing: { ts: string | null; record: Record<string, unknown> | null } | null,\n incomingRecord: Record<string, unknown> | null,\n closedPeriods: readonly PeriodRecord[],\n scope?: { collection: string; resolve?: PartitionResolver },\n): void {\n // #1005 — a period only governs records that resolve to ITS timeline. With no\n // resolver (the default `withPeriods()`), nothing resolves to a partition, so\n // every record sits on the vault-wide timeline exactly as before and a\n // partitioned period governs nothing.\n const partitionOf = (r: Record<string, unknown> | null): PeriodPartition | undefined => {\n if (!r || !scope?.resolve) return undefined\n return scope.resolve(scope.collection, r)\n }\n const existingRecord = existing?.record ?? null\n const existingPartition = partitionOf(existingRecord)\n const incomingPartition = partitionOf(incomingRecord)\n // One instant for the whole check, so a bounded reopen window cannot expire\n // between two periods in the same loop and seal a write half-way.\n const now = new Date().toISOString()\n\n for (const p of closedPeriods) {\n if (p.kind !== 'closed') continue\n // #1022 — a reopened period is writable again. This is the ONLY thing a\n // reopen does: it withdraws the period's veto. It cannot grant a write that\n // some other gate forbids, because the guard bus ANDs every handler and\n // record-level guards are registered ahead of this one.\n if (isEffectivelyReopened(p, now)) continue\n if (p.dateField) {\n const checkRecord = (\n label: string,\n r: Record<string, unknown> | null,\n recordPartition: PeriodPartition | undefined,\n ): void => {\n if (!r) return\n // Both sides are checked under their OWN partition, which is what stops\n // a write from sliding a record either INTO or OUT OF a sealed\n // timeline by rewriting the fields the subject mapping reads.\n if (!samePartition(recordPartition, p.partition)) return\n const v = r[p.dateField!]\n if (typeof v === 'string' && v <= p.endDate) {\n throw new PeriodClosedError(p.name, p.endDate, `${label}[${p.dateField}]=${v}`)\n }\n }\n checkRecord('existing', existingRecord, existingPartition)\n checkRecord('incoming', incomingRecord, incomingPartition)\n continue\n }\n // Fallback: write-time seal via envelope _ts. Scoped by the EXISTING\n // record's partition — `_ts` belongs to the stored envelope, so the\n // incoming side has no write-time of its own to compare.\n if (!samePartition(existingPartition, p.partition)) continue\n const existingTs = existing?.ts ?? null\n if (existingTs !== null && existingTs <= p.endDate) {\n throw new PeriodClosedError(p.name, p.endDate, existingTs)\n }\n }\n}\n\n/**\n * Sanity-check a proposed period name + endDate against existing\n * records. Shared by closePeriod / openPeriod so the two pathways\n * produce identical diagnostics.\n *\n * @internal\n */\nexport function validatePeriodName(\n name: string,\n existing: readonly PeriodRecord[],\n partition?: PeriodPartition,\n): void {\n if (name.length === 0) {\n throw new ValidationError('Period name cannot be empty.')\n }\n // Validates the components as a side effect — an empty or non-finite\n // component must be rejected at the call, not encoded into a storage key.\n resolvePeriodKey(name, partition)\n // #1005 — uniqueness is per TIMELINE. `'2026-06'` in `['A','vat']` does not\n // collide with `'2026-06'` in `['B','vat']`.\n if (existing.some((p) => p.name === name && samePartition(p.partition, partition))) {\n const where = partition && partition.length > 0\n ? ` in partition [${partition.join(', ')}]`\n : ''\n throw new ValidationError(`Period \"${name}\" already exists${where}.`)\n }\n}\n\n/**\n * Wire a reserved-collection ledger append for a period record. The\n * period itself is stored via the adapter as an encrypted envelope;\n * the ledger entry is a normal `put` with the period's payloadHash,\n * so period closures inherit the chain's tamper-evidence.\n *\n * @internal\n */\nexport async function appendPeriodLedgerEntry(\n ledger: LedgerStore | null,\n actor: string,\n envelope: EncryptedEnvelope,\n name: string,\n collection: string = PERIODS_COLLECTION,\n): Promise<void> {\n if (!ledger) return\n const { envelopePayloadHash } = await import('../../with-commit/history/ledger/index.js')\n await ledger.append({\n op: 'put',\n collection,\n id: name,\n version: envelope._v,\n actor,\n payloadHash: await envelopePayloadHash(envelope),\n })\n}\n\n/**\n * @internal #615. Sweep delete markers with `_ts < before` off ANY store\n * (the vault's local adapter, or a push-only sync target). Returns the count\n * removed. Shared by `vault._purgeDeleteMarkers` (local) and\n * `vault._purgePeriodTargets` (push-only targets).\n */\nexport async function purgeMarkersOn(\n store: NoydbStore,\n vault: string,\n before: string,\n collections?: string[],\n): Promise<number> {\n const snapshot = await store.loadAll(vault)\n let removed = 0\n for (const [coll, records] of Object.entries(snapshot)) {\n if (collections && !collections.includes(coll)) continue\n for (const [id, env] of Object.entries(records)) {\n if (isDeleteMarker(env) && env._ts < before) {\n await store.delete(vault, coll, id)\n removed++\n }\n }\n }\n return removed\n}\n","/**\n * Active periods strategy factory. Only reachable through the\n * `@noy-db/hub/periods` subpath.\n */\n\nimport {\n loadPeriods,\n chainAnchor,\n assertTsWritable,\n validatePeriodName,\n appendPeriodLedgerEntry,\n} from './periods.js'\nimport type { PeriodPartition, PartitionResolver } from './periods.js'\nimport type { PeriodsStrategy } from './strategy.js'\n\n/** Options for {@link withPeriods}. */\nexport interface WithPeriodsOptions {\n /**\n * Maps a collection to the timeline each of its records belongs to (#1005) —\n * the answer to \"which close calendar governs THIS record\".\n *\n * ```ts\n * withPeriods({\n * subjects: { receipts: (r) => [r.clientId, layerOf(r)] },\n * })\n * ```\n *\n * Same shape as `withForget({ subjects })`, which answers the same question\n * for erasure. A collection with no entry — and every collection when\n * `subjects` is omitted entirely — stays on the vault-wide timeline, so an\n * existing vault behaves exactly as it did before partitions existed.\n *\n * Return `undefined` from a mapper to put an individual record back on the\n * vault-wide timeline (e.g. a record that predates the field the mapping\n * reads).\n */\n readonly subjects?: Readonly<\n Record<string, (record: Record<string, unknown>) => PeriodPartition | undefined>\n >\n}\n\n/**\n * Build the default periods strategy. Pass into\n * `createNoydb({ periodsStrategy: withPeriods() })` to enable\n * `vault.closePeriod()` / `vault.openPeriod()` / write-guards.\n *\n * Pass `subjects` to run more than one close calendar in a single vault — see\n * {@link WithPeriodsOptions.subjects}.\n */\nexport function withPeriods(options?: WithPeriodsOptions): PeriodsStrategy {\n const subjects = options?.subjects\n const partitionOf: PartitionResolver | undefined = subjects\n ? (collection, record) => subjects[collection]?.(record)\n : undefined\n\n return {\n loadPeriods,\n chainAnchor,\n assertTsWritable,\n validatePeriodName,\n appendPeriodLedgerEntry,\n ...(partitionOf !== undefined && { partitionOf }),\n }\n}\n","/**\n * Strategy seam for the optional accounting-periods service. Core\n * imports `PeriodsStrategy` type-only + `NO_PERIODS` stub; the real\n * `loadPeriods` / `chainAnchor` / `assertTsWritable` /\n * `validatePeriodName` / `appendPeriodLedgerEntry` functions are\n * only reachable via `withPeriods()` in `./active.ts`.\n *\n * Applications that never call `vault.closePeriod()` /\n * `vault.openPeriod()` ship none of the ~363 LOC.\n *\n * @internal\n */\n\nimport type { EncryptedEnvelope, NoydbStore } from '../../kernel/types.js'\nimport type { LedgerStore } from '../../with-commit/history/ledger/store.js'\nimport type { PeriodRecord, PeriodPartition, PartitionResolver } from './periods.js'\n\n/**\n * @internal\n */\nexport interface PeriodsStrategy {\n loadPeriods(\n adapter: NoydbStore,\n vault: string,\n decrypt: (envelope: EncryptedEnvelope) => Promise<PeriodRecord>,\n ): Promise<PeriodRecord[]>\n chainAnchor(records: readonly PeriodRecord[], partition?: PeriodPartition): Promise<{\n priorPeriodName?: string\n priorPeriodHash: string\n }>\n assertTsWritable(\n existing: { ts: string | null; record: Record<string, unknown> | null } | null,\n incoming: Record<string, unknown> | null,\n periods: readonly PeriodRecord[],\n scope?: { collection: string; resolve?: PartitionResolver },\n ): void\n validatePeriodName(\n name: string,\n existing: readonly PeriodRecord[],\n partition?: PeriodPartition,\n ): void\n /**\n * Record → timeline resolver built from `withPeriods({ subjects })` (#1005).\n * `undefined` when the caller configured no subjects, which keeps every\n * record on the vault-wide timeline.\n */\n readonly partitionOf?: PartitionResolver\n appendPeriodLedgerEntry(\n ledger: LedgerStore | null,\n actor: string,\n envelope: EncryptedEnvelope,\n periodName: string,\n collection?: string,\n ): Promise<void>\n}\n\n/**\n * No-periods stub. `loadPeriods` returns `[]`; the write-guards do\n * nothing (vaults without closed periods never reject writes);\n * `validatePeriodName` / `appendPeriodLedgerEntry` throw because\n * those paths are only reached when the user explicitly called\n * `closePeriod()` / `openPeriod()` — if they did that without the\n * strategy, they need to wire it.\n *\n * @internal\n */\nconst NOT_ENABLED = new Error(\n 'Accounting periods require the periods strategy. Import ' +\n '`{ withPeriods }` from \"@noy-db/hub/periods\" and pass it to ' +\n '`createNoydb({ periodsStrategy: withPeriods() })`.',\n)\n\nexport const NO_PERIODS: PeriodsStrategy = {\n async loadPeriods() { return [] },\n async chainAnchor() { return { priorPeriodHash: '' } },\n assertTsWritable() {},\n validatePeriodName() { throw NOT_ENABLED },\n async appendPeriodLedgerEntry() { throw NOT_ENABLED },\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA2PO,SAAS,mBAAmB,QAOjC;AACA,MAAI;AACJ,MAAI;AACJ,MAAI,cAAc;AAClB,aAAW,KAAK,QAAQ;AACtB,QAAI,EAAE,OAAO,UAAU;AACrB,mBAAa;AACb,sBAAgB;AAChB;AAAA,IACF,WAAW,eAAe,QAAW;AACnC,sBAAgB,EAAE;AAAA,IACpB;AAAA,EACF;AACA,MAAI,CAAC,WAAY,QAAO,EAAE,YAAY;AACtC,SAAO;AAAA,IACL,YAAY,WAAW;AAAA,IACvB,YAAY,WAAW;AAAA,IACvB,GAAI,WAAW,UAAU,UAAa,EAAE,eAAe,WAAW,MAAM;AAAA,IACxE,GAAI,WAAW,WAAW,UAAa,EAAE,cAAc,WAAW,OAAO;AAAA,IACzE,GAAI,kBAAkB,UAAa,EAAE,YAAY,cAAc;AAAA,IAC/D;AAAA,EACF;AACF;AAWO,SAAS,sBAAsB,QAAsB,QAAyB;AACnF,MAAI,OAAO,eAAe,OAAW,QAAO;AAC5C,MAAI,OAAO,eAAe,UAAa,OAAO,cAAc,OAAO,WAAY,QAAO;AACtF,MAAI,OAAO,kBAAkB,UAAa,SAAS,OAAO,cAAe,QAAO;AAChF,SAAO;AACT;AAuCO,SAAS,iBAAiB,MAAc,WAAqC;AAClF,MAAI,CAAC,aAAa,UAAU,WAAW,EAAG,QAAO;AACjD,QAAM,QAAQ,UAAU,IAAI,CAAC,MAAM;AACjC,QAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,GAAG;AAChD,YAAM,IAAI,gBAAgB,2DAA2D,CAAC,EAAE;AAAA,IAC1F;AACA,UAAM,IAAI,OAAO,CAAC;AAClB,QAAI,MAAM,IAAI;AACZ,YAAM,IAAI,gBAAgB,8CAA8C;AAAA,IAC1E;AACA,WAAO,mBAAmB,CAAC;AAAA,EAC7B,CAAC;AACD,SAAO,GAAG,IAAI,KAAO,MAAM,KAAK,GAAG,CAAC;AACtC;AAQO,SAAS,cAAc,GAAqB,GAA8B;AAC/E,QAAM,IAAI,KAAK,CAAC;AAChB,QAAM,IAAI,KAAK,CAAC;AAChB,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,SAAO,EAAE,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC,CAAC;AACrD;AAmOA,eAAsB,YACpB,SACA,OACA,SACyB;AACzB,QAAM,MAAM,MAAM,QAAQ,KAAK,OAAO,kBAAkB;AACxD,QAAM,UAA0B,CAAC;AACjC,aAAW,MAAM,KAAK;AACpB,UAAM,MAAM,MAAM,QAAQ,IAAI,OAAO,oBAAoB,EAAE;AAC3D,QAAI,IAAK,SAAQ,KAAK,MAAM,QAAQ,GAAG,CAAC;AAAA,EAC1C;AAEA,UAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,QAAQ,CAAC;AAC3D,SAAO;AACT;AASA,eAAsB,YACpB,SACA,WACgE;AAKhE,QAAM,aAAa,QAAQ,OAAO,CAAC,MAAM,cAAc,EAAE,WAAW,SAAS,CAAC;AAC9E,QAAM,OAAO,WAAW,WAAW,SAAS,CAAC;AAC7C,MAAI,CAAC,KAAM,QAAO,EAAE,iBAAiB,GAAG;AACxC,QAAM,OAAO,MAAM,UAAU,cAAc,IAA0C,CAAC;AACtF,SAAO,EAAE,iBAAiB,KAAK,MAAM,iBAAiB,KAAK;AAC7D;AAoBO,SAAS,iBACd,UACA,gBACA,eACA,OACM;AAKN,QAAM,cAAc,CAAC,MAAmE;AACtF,QAAI,CAAC,KAAK,CAAC,OAAO,QAAS,QAAO;AAClC,WAAO,MAAM,QAAQ,MAAM,YAAY,CAAC;AAAA,EAC1C;AACA,QAAM,iBAAiB,UAAU,UAAU;AAC3C,QAAM,oBAAoB,YAAY,cAAc;AACpD,QAAM,oBAAoB,YAAY,cAAc;AAGpD,QAAM,OAAM,oBAAI,KAAK,GAAE,YAAY;AAEnC,aAAW,KAAK,eAAe;AAC7B,QAAI,EAAE,SAAS,SAAU;AAKzB,QAAI,sBAAsB,GAAG,GAAG,EAAG;AACnC,QAAI,EAAE,WAAW;AACf,YAAM,cAAc,CAClB,OACA,GACA,oBACS;AACT,YAAI,CAAC,EAAG;AAIR,YAAI,CAAC,cAAc,iBAAiB,EAAE,SAAS,EAAG;AAClD,cAAM,IAAI,EAAE,EAAE,SAAU;AACxB,YAAI,OAAO,MAAM,YAAY,KAAK,EAAE,SAAS;AAC3C,gBAAM,IAAI,kBAAkB,EAAE,MAAM,EAAE,SAAS,GAAG,KAAK,IAAI,EAAE,SAAS,KAAK,CAAC,EAAE;AAAA,QAChF;AAAA,MACF;AACA,kBAAY,YAAY,gBAAgB,iBAAiB;AACzD,kBAAY,YAAY,gBAAgB,iBAAiB;AACzD;AAAA,IACF;AAIA,QAAI,CAAC,cAAc,mBAAmB,EAAE,SAAS,EAAG;AACpD,UAAM,aAAa,UAAU,MAAM;AACnC,QAAI,eAAe,QAAQ,cAAc,EAAE,SAAS;AAClD,YAAM,IAAI,kBAAkB,EAAE,MAAM,EAAE,SAAS,UAAU;AAAA,IAC3D;AAAA,EACF;AACF;AASO,SAAS,mBACd,MACA,UACA,WACM;AACN,MAAI,KAAK,WAAW,GAAG;AACrB,UAAM,IAAI,gBAAgB,8BAA8B;AAAA,EAC1D;AAGA,mBAAiB,MAAM,SAAS;AAGhC,MAAI,SAAS,KAAK,CAAC,MAAM,EAAE,SAAS,QAAQ,cAAc,EAAE,WAAW,SAAS,CAAC,GAAG;AAClF,UAAM,QAAQ,aAAa,UAAU,SAAS,IAC1C,kBAAkB,UAAU,KAAK,IAAI,CAAC,MACtC;AACJ,UAAM,IAAI,gBAAgB,WAAW,IAAI,mBAAmB,KAAK,GAAG;AAAA,EACtE;AACF;AAUA,eAAsB,wBACpB,QACA,OACA,UACA,MACA,aAAqB,oBACN;AACf,MAAI,CAAC,OAAQ;AACb,QAAM,EAAE,oBAAoB,IAAI,MAAM,OAAO,sBAA2C;AACxF,QAAM,OAAO,OAAO;AAAA,IAClB,IAAI;AAAA,IACJ;AAAA,IACA,IAAI;AAAA,IACJ,SAAS,SAAS;AAAA,IAClB;AAAA,IACA,aAAa,MAAM,oBAAoB,QAAQ;AAAA,EACjD,CAAC;AACH;AAQA,eAAsB,eACpB,OACA,OACA,QACA,aACiB;AACjB,QAAM,WAAW,MAAM,MAAM,QAAQ,KAAK;AAC1C,MAAI,UAAU;AACd,aAAW,CAAC,MAAM,OAAO,KAAK,OAAO,QAAQ,QAAQ,GAAG;AACtD,QAAI,eAAe,CAAC,YAAY,SAAS,IAAI,EAAG;AAChD,eAAW,CAAC,IAAI,GAAG,KAAK,OAAO,QAAQ,OAAO,GAAG;AAC/C,UAAI,eAAe,GAAG,KAAK,IAAI,MAAM,QAAQ;AAC3C,cAAM,MAAM,OAAO,OAAO,MAAM,EAAE;AAClC;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;AC7tBO,SAAS,YAAY,SAA+C;AACzE,QAAM,WAAW,SAAS;AAC1B,QAAM,cAA6C,WAC/C,CAAC,YAAY,WAAW,SAAS,UAAU,IAAI,MAAM,IACrD;AAEJ,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAI,gBAAgB,UAAa,EAAE,YAAY;AAAA,EACjD;AACF;;;ACGA,IAAM,cAAc,IAAI;AAAA,EACtB;AAGF;AAEO,IAAM,aAA8B;AAAA,EACzC,MAAM,cAAc;AAAE,WAAO,CAAC;AAAA,EAAE;AAAA,EAChC,MAAM,cAAc;AAAE,WAAO,EAAE,iBAAiB,GAAG;AAAA,EAAE;AAAA,EACrD,mBAAmB;AAAA,EAAC;AAAA,EACpB,qBAAqB;AAAE,UAAM;AAAA,EAAY;AAAA,EACzC,MAAM,0BAA0B;AAAE,UAAM;AAAA,EAAY;AACtD;","names":[]}
@@ -7,7 +7,7 @@ import {
7
7
  PERIOD_FREEZES_COLLECTION,
8
8
  PERIOD_TARGET_PURGES_COLLECTION,
9
9
  periodExclusiveUpperBound
10
- } from "./chunk-JXIDGFKS.js";
10
+ } from "./chunk-HPLWFNG4.js";
11
11
  import {
12
12
  envelopeBodySize
13
13
  } from "./chunk-3URQC7EH.js";
@@ -1160,4 +1160,4 @@ export {
1160
1160
  SyncEngine,
1161
1161
  SyncTransaction
1162
1162
  };
1163
- //# sourceMappingURL=chunk-J7FM3RAU.js.map
1163
+ //# sourceMappingURL=chunk-BRVW2GS5.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createNoydb
3
- } from "./chunk-WK5XUYYF.js";
3
+ } from "./chunk-2GAJGUNM.js";
4
4
  import {
5
5
  deriveSchemaManifest
6
6
  } from "./chunk-2DHSSLOD.js";
@@ -290,4 +290,4 @@ export {
290
290
  followRedirects,
291
291
  open
292
292
  };
293
- //# sourceMappingURL=chunk-H73G4XF4.js.map
293
+ //# sourceMappingURL=chunk-BWVKEOPE.js.map
@@ -7,6 +7,7 @@ var PERIODS_COLLECTION = "_periods";
7
7
  var PERIOD_FREEZES_COLLECTION = "_period_freezes";
8
8
  var PERIOD_ARCHIVES_COLLECTION = "_period_archives";
9
9
  var PERIOD_TARGET_PURGES_COLLECTION = "_period_target_purges";
10
+ var PERIOD_REOPENS_COLLECTION = "_period_reopens";
10
11
  function periodExclusiveUpperBound(endDate) {
11
12
  const ms = Date.parse(endDate);
12
13
  if (Number.isNaN(ms)) throw new ValidationError(`freezePeriod: unparseable period endDate "${endDate}".`);
@@ -19,6 +20,7 @@ export {
19
20
  PERIOD_FREEZES_COLLECTION,
20
21
  PERIOD_ARCHIVES_COLLECTION,
21
22
  PERIOD_TARGET_PURGES_COLLECTION,
23
+ PERIOD_REOPENS_COLLECTION,
22
24
  periodExclusiveUpperBound
23
25
  };
24
- //# sourceMappingURL=chunk-JXIDGFKS.js.map
26
+ //# sourceMappingURL=chunk-HPLWFNG4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-audit/periods/window.ts"],"sourcesContent":["/**\n * Pure period-window primitives — the reserved collection names and the\n * exclusive-upper-bound computation shared by freeze (#604), archive (#613),\n * target-purge (#615) and the period-scoped sync pull (#807).\n *\n * Deliberately dependency-light (kernel errors only): the sync engine\n * (`with-sync/period-scope.ts`) imports THIS module, not\n * `periods.ts`, so a period-scoped pull never drags the ledger hash-chain\n * machinery (`sha256Hex`/`canonicalJson`) into a bundle that opted out of\n * the periods service.\n */\nimport { ValidationError } from '../../kernel/errors.js'\n\n/** The reserved collection name holding closed-period metadata. */\nexport const PERIODS_COLLECTION = '_periods'\n\n/** Sibling of {@link PERIODS_COLLECTION} holding freeze companions (#604). */\nexport const PERIOD_FREEZES_COLLECTION = '_period_freezes'\n\n/** Sibling of {@link PERIODS_COLLECTION} holding archive companions (#613). */\nexport const PERIOD_ARCHIVES_COLLECTION = '_period_archives'\n\n/** Sibling of {@link PERIODS_COLLECTION} holding target-purge companions (#615). */\nexport const PERIOD_TARGET_PURGES_COLLECTION = '_period_target_purges'\n\n/**\n * Sibling of {@link PERIODS_COLLECTION} holding reopen/reclose companions (#1022).\n *\n * Unlike the freeze / archive / target-purge companions — each a single,\n * idempotent, once-only record — this one holds an APPEND-ONLY EVENT LIST,\n * because reopening is a repeating cycle: a month can be closed, reopened,\n * reclosed, and reopened again. The audit value is the sequence, not the\n * latest state.\n */\nexport const PERIOD_REOPENS_COLLECTION = '_period_reopens'\n\n/**\n * Exclusive upper bound for a period's delete-marker purge window (#604).\n * Markers carry no business date (empty body), only write-time `_ts`, so freeze\n * purges markers with `_ts < bound`, `bound` being the instant just after the\n * period's inclusive `endDate`: a date-only `endDate` seals through end-of-day\n * → next midnight; a full-timestamp `endDate` seals through that instant → +1ms.\n */\nexport function periodExclusiveUpperBound(endDate: string): string {\n const ms = Date.parse(endDate)\n if (Number.isNaN(ms)) throw new ValidationError(`freezePeriod: unparseable period endDate \"${endDate}\".`)\n const dateOnly = /^\\d{4}-\\d{2}-\\d{2}$/.test(endDate)\n return new Date(ms + (dateOnly ? 86_400_000 : 1)).toISOString()\n}\n"],"mappings":";;;;;AAcO,IAAM,qBAAqB;AAG3B,IAAM,4BAA4B;AAGlC,IAAM,6BAA6B;AAGnC,IAAM,kCAAkC;AAWxC,IAAM,4BAA4B;AASlC,SAAS,0BAA0B,SAAyB;AACjE,QAAM,KAAK,KAAK,MAAM,OAAO;AAC7B,MAAI,OAAO,MAAM,EAAE,EAAG,OAAM,IAAI,gBAAgB,6CAA6C,OAAO,IAAI;AACxG,QAAM,WAAW,sBAAsB,KAAK,OAAO;AACnD,SAAO,IAAI,KAAK,MAAM,WAAW,QAAa,EAAE,EAAE,YAAY;AAChE;","names":[]}
@@ -399,7 +399,7 @@ async function createOwnerOnAdoptedPartition(store, vaultName, opts) {
399
399
  }
400
400
  }
401
401
  if (isManaged(opts)) {
402
- const { createNoydb } = await import("./noydb-3MT74MZS.js");
402
+ const { createNoydb } = await import("./noydb-ASEXNJS6.js");
403
403
  const db = await createNoydb({
404
404
  store,
405
405
  user: userId,
@@ -455,4 +455,4 @@ export {
455
455
  withCargo,
456
456
  describeExtraction
457
457
  };
458
- //# sourceMappingURL=chunk-IMZLOJTK.js.map
458
+ //# sourceMappingURL=chunk-RV3ARMPN.js.map
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  decryptExtractedPartition,
55
55
  diffVault,
56
56
  withCargo
57
- } from "./chunk-IMZLOJTK.js";
57
+ } from "./chunk-RV3ARMPN.js";
58
58
  import "./chunk-4GZ6XOVW.js";
59
59
  import {
60
60
  createDeedOwner,
@@ -110,7 +110,7 @@ import {
110
110
  PresenceHandle,
111
111
  SyncEngine,
112
112
  SyncTransaction
113
- } from "./chunk-J7FM3RAU.js";
113
+ } from "./chunk-BRVW2GS5.js";
114
114
  import {
115
115
  isDiscriminant
116
116
  } from "./chunk-PY4KJPYU.js";
@@ -167,7 +167,7 @@ import {
167
167
  verifyRedirect,
168
168
  wrapBundleStore,
169
169
  wrapPodStore
170
- } from "./chunk-H73G4XF4.js";
170
+ } from "./chunk-BWVKEOPE.js";
171
171
  import {
172
172
  Collection,
173
173
  ELEVATION_AUDIT_COLLECTION,
@@ -186,7 +186,7 @@ import {
186
186
  refArray,
187
187
  via,
188
188
  withArchive
189
- } from "./chunk-WK5XUYYF.js";
189
+ } from "./chunk-2GAJGUNM.js";
190
190
  import "./chunk-HVQGCKY4.js";
191
191
  import "./chunk-4HDYQL3J.js";
192
192
  import {
@@ -253,11 +253,11 @@ import {
253
253
  } from "./chunk-55LKVALY.js";
254
254
  import "./chunk-VJFMXYKP.js";
255
255
  import "./chunk-PEOZ34EM.js";
256
- import "./chunk-PXCBVDFY.js";
256
+ import "./chunk-5O5HRRTV.js";
257
257
  import "./chunk-GHVIKOHT.js";
258
258
  import {
259
259
  PERIODS_COLLECTION
260
- } from "./chunk-JXIDGFKS.js";
260
+ } from "./chunk-HPLWFNG4.js";
261
261
  import "./chunk-SMY5X46Z.js";
262
262
  import "./chunk-DZFQVICS.js";
263
263
  import {
@@ -29,7 +29,7 @@ import { type ForgetResult } from '../with-audit/forget/strategy.js';
29
29
  import { type SubjectRef } from '../with-audit/forget/subject-index.js';
30
30
  import type { VaultFrame } from '../with-fork/shadow/vault-frame.js';
31
31
  import type { ConsentContext, ConsentAuditEntry, ConsentAuditFilter, ConsentOp } from '../with-audit/consent/consent.js';
32
- import { type PeriodScope, type PeriodGuardPrior } from '../with-audit/periods/vault-facade.js';
32
+ import { type PeriodScope, type PeriodGuardPrior, type ReopenPeriodOptions, type PeriodScopeWithReason } from '../with-audit/periods/vault-facade.js';
33
33
  import { type RefDescriptor, type RefViolation } from './refs.js';
34
34
  import type { DictionaryHandle, DictionaryOptions } from '../port/with/i18n-strategy.js';
35
35
  import { type LinkSpec, type LinkSetHandle } from '../with-shape/links/names.js';
@@ -42,7 +42,7 @@ import type { DerivationRegistry } from '../with-formula/derivations/registry.js
42
42
  import type { DerivationStrategy } from '../with-formula/derivations/types.js';
43
43
  import { type BehaviorSummary } from '../with-shape/introspection/behaviors.js';
44
44
  import type { JoinedHandle } from '../with-shape/satellites/types.js';
45
- import { type PeriodRecord, type ClosePeriodOptions, type OpenPeriodOptions } from '../with-audit/periods/index.js';
45
+ import { type PeriodRecord, type PeriodReopenEvent, type ClosePeriodOptions, type OpenPeriodOptions } from '../with-audit/periods/index.js';
46
46
  import { type EnclaveKey } from './enclave/index.js';
47
47
  import type { RecipientSealer } from '../with-party/team/managed-secret.js';
48
48
  import { type ExportBlobsOptions, type ExportBlobsHandle } from '../with-shape/blobs/export-blobs.js';
@@ -1267,6 +1267,12 @@ export declare class Vault {
1267
1267
  * companion and is re-runnable.
1268
1268
  */
1269
1269
  purgePeriodTargets(name: string, options?: PeriodScope): Promise<PeriodRecord>;
1270
+ /** Return a closed period to writable (#1022); logged append-only, close chain untouched. */
1271
+ reopenPeriod(name: string, options?: ReopenPeriodOptions): Promise<PeriodRecord>;
1272
+ /** Seal a reopened period again (#1022) rather than waiting for its `until` to lapse. */
1273
+ reclosePeriod(name: string, options?: PeriodScopeWithReason): Promise<PeriodRecord>;
1274
+ /** The append-only reopen/reclose log for one period (#1022), in order. */
1275
+ listPeriodReopens(name: string, options?: PeriodScope): Promise<readonly PeriodReopenEvent[]>;
1270
1276
  /** @internal — called by the gate bus before put/delete. */
1271
1277
  _assertTsWritable(existing: PeriodGuardPrior | null, incoming: Record<string, unknown> | null, collection?: string): Promise<void>;
1272
1278
  /** List all collection names in this vault. */
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Noydb,
3
3
  createNoydb
4
- } from "./chunk-WK5XUYYF.js";
4
+ } from "./chunk-2GAJGUNM.js";
5
5
  import "./chunk-HVQGCKY4.js";
6
6
  import "./chunk-4HDYQL3J.js";
7
7
  import "./chunk-EPFF7LOX.js";
@@ -26,9 +26,9 @@ import "./chunk-OF3L2SD6.js";
26
26
  import "./chunk-55LKVALY.js";
27
27
  import "./chunk-VJFMXYKP.js";
28
28
  import "./chunk-PEOZ34EM.js";
29
- import "./chunk-PXCBVDFY.js";
29
+ import "./chunk-5O5HRRTV.js";
30
30
  import "./chunk-GHVIKOHT.js";
31
- import "./chunk-JXIDGFKS.js";
31
+ import "./chunk-HPLWFNG4.js";
32
32
  import "./chunk-SMY5X46Z.js";
33
33
  import "./chunk-DZFQVICS.js";
34
34
  import "./chunk-35EBKWQU.js";
@@ -80,4 +80,4 @@ export {
80
80
  Noydb,
81
81
  createNoydb
82
82
  };
83
- //# sourceMappingURL=noydb-3MT74MZS.js.map
83
+ //# sourceMappingURL=noydb-ASEXNJS6.js.map
@@ -3,21 +3,24 @@ import {
3
3
  appendPeriodLedgerEntry,
4
4
  assertTsWritable,
5
5
  chainAnchor,
6
+ isEffectivelyReopened,
6
7
  loadPeriods,
7
8
  purgeMarkersOn,
8
9
  resolvePeriodKey,
10
+ resolveReopenState,
9
11
  samePartition,
10
12
  validatePeriodName,
11
13
  withPeriods
12
- } from "../chunk-PXCBVDFY.js";
14
+ } from "../chunk-5O5HRRTV.js";
13
15
  import "../chunk-GHVIKOHT.js";
14
16
  import {
15
17
  PERIODS_COLLECTION,
16
18
  PERIOD_ARCHIVES_COLLECTION,
17
19
  PERIOD_FREEZES_COLLECTION,
20
+ PERIOD_REOPENS_COLLECTION,
18
21
  PERIOD_TARGET_PURGES_COLLECTION,
19
22
  periodExclusiveUpperBound
20
- } from "../chunk-JXIDGFKS.js";
23
+ } from "../chunk-HPLWFNG4.js";
21
24
  import "../chunk-Q6TEJXBX.js";
22
25
  import "../chunk-I7FD46FF.js";
23
26
  import "../chunk-6SAFL5YE.js";
@@ -36,14 +39,17 @@ export {
36
39
  PERIODS_COLLECTION,
37
40
  PERIOD_ARCHIVES_COLLECTION,
38
41
  PERIOD_FREEZES_COLLECTION,
42
+ PERIOD_REOPENS_COLLECTION,
39
43
  PERIOD_TARGET_PURGES_COLLECTION,
40
44
  appendPeriodLedgerEntry,
41
45
  assertTsWritable,
42
46
  chainAnchor,
47
+ isEffectivelyReopened,
43
48
  loadPeriods,
44
49
  periodExclusiveUpperBound,
45
50
  purgeMarkersOn,
46
51
  resolvePeriodKey,
52
+ resolveReopenState,
47
53
  samePartition,
48
54
  validatePeriodName,
49
55
  withPeriods
package/dist/pod/index.js CHANGED
@@ -7,8 +7,8 @@ import {
7
7
  verifyRedirect,
8
8
  wrapBundleStore,
9
9
  wrapPodStore
10
- } from "../chunk-H73G4XF4.js";
11
- import "../chunk-WK5XUYYF.js";
10
+ } from "../chunk-BWVKEOPE.js";
11
+ import "../chunk-2GAJGUNM.js";
12
12
  import "../chunk-HVQGCKY4.js";
13
13
  import "../chunk-4HDYQL3J.js";
14
14
  import {
@@ -40,9 +40,9 @@ import "../chunk-OF3L2SD6.js";
40
40
  import "../chunk-55LKVALY.js";
41
41
  import "../chunk-VJFMXYKP.js";
42
42
  import "../chunk-PEOZ34EM.js";
43
- import "../chunk-PXCBVDFY.js";
43
+ import "../chunk-5O5HRRTV.js";
44
44
  import "../chunk-GHVIKOHT.js";
45
- import "../chunk-JXIDGFKS.js";
45
+ import "../chunk-HPLWFNG4.js";
46
46
  import "../chunk-SMY5X46Z.js";
47
47
  import "../chunk-DZFQVICS.js";
48
48
  import {
@@ -2,11 +2,11 @@ import {
2
2
  PresenceHandle,
3
3
  SyncEngine,
4
4
  SyncTransaction
5
- } from "../chunk-J7FM3RAU.js";
5
+ } from "../chunk-BRVW2GS5.js";
6
6
  import {
7
7
  NO_SYNC
8
8
  } from "../chunk-55LKVALY.js";
9
- import "../chunk-JXIDGFKS.js";
9
+ import "../chunk-HPLWFNG4.js";
10
10
  import "../chunk-3URQC7EH.js";
11
11
  import "../chunk-7H73RIE3.js";
12
12
  import "../chunk-HXGUIPQB.js";
@@ -15,8 +15,8 @@
15
15
  export { withPeriods } from './active.js';
16
16
  export type { WithPeriodsOptions } from './active.js';
17
17
  export type { PeriodsStrategy } from './strategy.js';
18
- export type { PeriodScope } from './vault-facade.js';
19
- export { PERIODS_COLLECTION, PERIOD_FREEZES_COLLECTION, PERIOD_ARCHIVES_COLLECTION, PERIOD_TARGET_PURGES_COLLECTION, periodExclusiveUpperBound, resolvePeriodKey, samePartition, loadPeriods, chainAnchor, assertTsWritable, validatePeriodName, appendPeriodLedgerEntry, purgeMarkersOn, } from './periods.js';
20
- export type { PeriodPartition, PartitionResolver, PeriodRecord, PeriodFreezeRecord, PeriodArchiveRecord, PeriodTargetPurgeRecord, TargetPurgeCount, ClosePeriodOptions, OpenPeriodOptions, CarryForwardContext, ReadOnlyCollection, } from './periods.js';
18
+ export type { PeriodScope, PeriodScopeWithReason, ReopenPeriodOptions } from './vault-facade.js';
19
+ export { PERIODS_COLLECTION, PERIOD_FREEZES_COLLECTION, PERIOD_ARCHIVES_COLLECTION, PERIOD_TARGET_PURGES_COLLECTION, PERIOD_REOPENS_COLLECTION, periodExclusiveUpperBound, resolvePeriodKey, samePartition, resolveReopenState, isEffectivelyReopened, loadPeriods, chainAnchor, assertTsWritable, validatePeriodName, appendPeriodLedgerEntry, purgeMarkersOn, } from './periods.js';
20
+ export type { PeriodPartition, PartitionResolver, PeriodReopenEvent, PeriodReopenRecord, PeriodRecord, PeriodFreezeRecord, PeriodArchiveRecord, PeriodTargetPurgeRecord, TargetPurgeCount, ClosePeriodOptions, OpenPeriodOptions, CarryForwardContext, ReadOnlyCollection, } from './periods.js';
21
21
  /** The un-opted-in stub for this service — exported so callers can compare against it (#844). */
22
22
  export { NO_PERIODS } from './strategy.js';
@@ -146,7 +146,7 @@
146
146
  */
147
147
  import type { NoydbStore, EncryptedEnvelope } from '../../kernel/types.js';
148
148
  import type { LedgerStore } from '../../with-commit/history/ledger/index.js';
149
- export { PERIODS_COLLECTION, PERIOD_FREEZES_COLLECTION, PERIOD_ARCHIVES_COLLECTION, PERIOD_TARGET_PURGES_COLLECTION, periodExclusiveUpperBound, } from './window.js';
149
+ export { PERIODS_COLLECTION, PERIOD_FREEZES_COLLECTION, PERIOD_ARCHIVES_COLLECTION, PERIOD_TARGET_PURGES_COLLECTION, PERIOD_REOPENS_COLLECTION, periodExclusiveUpperBound, } from './window.js';
150
150
  /**
151
151
  * Companion record recording that a closed period was frozen (its delete
152
152
  * markers physically purged). Stored in {@link PERIOD_FREEZES_COLLECTION},
@@ -177,6 +177,73 @@ export interface TargetPurgeCount {
177
177
  readonly role: 'backup' | 'archive';
178
178
  readonly purgedCount: number;
179
179
  }
180
+ /**
181
+ * One entry in a period's append-only reopen/reclose log (#1022).
182
+ *
183
+ * Real accounting close is a three-state lifecycle — open / closed / reopened
184
+ * — not a one-way door. A month gets closed, a missing invoice arrives or a
185
+ * filing is rejected, the month is reopened, corrected, and reclosed. The audit
186
+ * value is not the ability to write again; it is the chain being able to say
187
+ * *closed at T1, reopened at T2 by U, reclosed at T3*.
188
+ */
189
+ export interface PeriodReopenEvent {
190
+ readonly op: 'reopen' | 'reclose';
191
+ /** ISO timestamp the event was recorded. */
192
+ readonly at: string;
193
+ /** userId of the keyring that performed it. */
194
+ readonly by: string;
195
+ /**
196
+ * `reopen` only — ISO instant after which the period re-seals on its own,
197
+ * with nobody acting. Absent means the window stays open until an explicit
198
+ * `reclosePeriod`.
199
+ */
200
+ readonly until?: string;
201
+ /** Free-text justification, carried verbatim into the audit trail. */
202
+ readonly reason?: string;
203
+ }
204
+ /**
205
+ * Companion holding a period's reopen/reclose history (#1022). Stored in
206
+ * {@link PERIOD_REOPENS_COLLECTION}, keyed by the period's storage key — kept
207
+ * OFF the hash-chained `_periods/<name>` record for the same reason freeze and
208
+ * archive are: reopening must never rewrite the close, or the chain that proves
209
+ * the close happened is the very thing the reopen destroys.
210
+ *
211
+ * `events` is APPEND-ONLY. Where the other companions are single-shot and
212
+ * idempotent, this one accumulates, because the cycle repeats.
213
+ */
214
+ export interface PeriodReopenRecord {
215
+ readonly period: string;
216
+ readonly partition?: PeriodPartition;
217
+ readonly events: readonly PeriodReopenEvent[];
218
+ }
219
+ /**
220
+ * Collapse an append-only reopen log into the return-only fields merged onto a
221
+ * {@link PeriodRecord} on read.
222
+ *
223
+ * Expiry is deliberately NOT resolved here: `reopenedUntil` is carried through
224
+ * verbatim and compared against the clock at write-guard time, so a bounded
225
+ * window re-seals on its own without anything having to run.
226
+ *
227
+ * @internal
228
+ */
229
+ export declare function resolveReopenState(events: readonly PeriodReopenEvent[]): {
230
+ reopenedAt?: string;
231
+ reopenedBy?: string;
232
+ reopenedUntil?: string;
233
+ reopenReason?: string;
234
+ reclosedAt?: string;
235
+ reopenCount: number;
236
+ };
237
+ /**
238
+ * Is this period writable right now on account of a reopen? (#1022)
239
+ *
240
+ * Three ways to be sealed again: never reopened, explicitly reclosed after the
241
+ * last reopen, or a bounded window that has elapsed. The clock is read by the
242
+ * caller and passed in, so the guard and any diagnostic agree on one instant.
243
+ *
244
+ * @internal
245
+ */
246
+ export declare function isEffectivelyReopened(period: PeriodRecord, nowIso: string): boolean;
180
247
  /**
181
248
  * Companion record noting that a closed+frozen period's delete markers were
182
249
  * swept off the vault's push-only sync targets (#615). Stored in
@@ -312,6 +379,19 @@ export interface PeriodRecord {
312
379
  readonly targetsPurgedAt?: string;
313
380
  readonly targetsPurgedBy?: string;
314
381
  readonly targetsPurged?: readonly TargetPurgeCount[];
382
+ /** #1022 return-only — collapsed from the `_period_reopens/<key>` append-only
383
+ * log on read; NEVER written into the stored `_periods/<name>` record, so a
384
+ * reopen cannot disturb the inter-period hash chain. Absent = never reopened.
385
+ * `reclosedAt` present (and >= `reopenedAt`) means the window was closed
386
+ * again explicitly; `reopenedUntil` in the past means it lapsed on its own.
387
+ * Use {@link isEffectivelyReopened} rather than reading these directly. */
388
+ readonly reopenedAt?: string;
389
+ readonly reopenedBy?: string;
390
+ readonly reopenedUntil?: string;
391
+ readonly reopenReason?: string;
392
+ readonly reclosedAt?: string;
393
+ /** How many times this period has been reopened, ever. */
394
+ readonly reopenCount?: number;
315
395
  }
316
396
  /** Options for `vault.closePeriod()`. */
317
397
  export interface ClosePeriodOptions {
@@ -3,11 +3,26 @@ import type { NoydbStore } from '../../kernel/types.js';
3
3
  import type { LedgerStore } from '../../with-commit/history/ledger/store.js';
4
4
  import type { Collection } from '../../kernel/collection.js';
5
5
  import type { PeriodsStrategy } from './strategy.js';
6
- import { type PeriodRecord, type TargetPurgeCount, type ClosePeriodOptions, type OpenPeriodOptions, type PeriodPartition } from './periods.js';
6
+ import { type PeriodRecord, type TargetPurgeCount, type ClosePeriodOptions, type OpenPeriodOptions, type PeriodPartition, type PeriodReopenEvent } from './periods.js';
7
7
  /** Selects one period timeline. Omit — or pass an empty tuple — for the vault-wide one (#1005). */
8
8
  export interface PeriodScope {
9
9
  readonly partition?: PeriodPartition;
10
10
  }
11
+ /** A scoped operation that also records a free-text justification in the audit trail. */
12
+ export interface PeriodScopeWithReason extends PeriodScope {
13
+ readonly reason?: string;
14
+ }
15
+ /** Options for `vault.reopenPeriod()` (#1022). */
16
+ export interface ReopenPeriodOptions extends PeriodScopeWithReason {
17
+ /**
18
+ * ISO instant after which the period re-seals BY ITSELF, with nobody acting.
19
+ * Omit for a window that stays open until an explicit `reclosePeriod()`.
20
+ *
21
+ * Expiry is evaluated against the clock on every write check, so a lapsed
22
+ * window needs no sweep, no timer and no cache invalidation to take effect.
23
+ */
24
+ readonly until?: string;
25
+ }
11
26
  /** The persisted side of a write, as the period write-guard sees it. */
12
27
  export interface PeriodGuardPrior {
13
28
  readonly ts: string | null;
@@ -111,6 +126,44 @@ export declare class VaultPeriods {
111
126
  * every pre-partition period is stored.
112
127
  */
113
128
  getPeriod(name: string, options?: PeriodScope): Promise<PeriodRecord | null>;
129
+ /**
130
+ * The full append-only reopen/reclose log for one period (#1022) — the record
131
+ * an audit actually asks for, in order. `[]` when never reopened.
132
+ */
133
+ listPeriodReopens(name: string, options?: PeriodScope): Promise<readonly PeriodReopenEvent[]>;
134
+ /**
135
+ * Return a closed period to a writable state (#1022).
136
+ *
137
+ * Close is a three-state lifecycle in practice — open / closed / reopened —
138
+ * because a month gets closed and then a missing invoice arrives, or a filing
139
+ * is rejected and must be amended. That is routine, and it is supposed to
140
+ * leave a trail.
141
+ *
142
+ * The `_periods/<key>` record is NOT touched: the reopen is appended to a
143
+ * companion log, so the inter-period hash chain that proves the close
144
+ * happened survives the reopen intact. The chain reads *closed at T1,
145
+ * reopened at T2 by U, reclosed at T3*.
146
+ *
147
+ * A reopen withdraws the PERIOD's veto and nothing else. Record-level rules —
148
+ * an `immutableGuard` on a sent receipt, a frozen field — are separate gate
149
+ * handlers registered ahead of the period gate, so a reopened month cannot
150
+ * resurrect a document that is independently locked. Period state can only
151
+ * ever widen what the record-level rule already permits.
152
+ *
153
+ * Reopening an already-open window is allowed and appends another event: that
154
+ * is how a window gets extended, and the log keeps both.
155
+ */
156
+ reopenPeriod(name: string, options?: ReopenPeriodOptions): Promise<PeriodRecord>;
157
+ /**
158
+ * Seal a reopened period again (#1022), ending the window explicitly rather
159
+ * than waiting for an `until` to lapse. Appends to the same log, so the
160
+ * close → reopen → reclose sequence is recoverable in order.
161
+ */
162
+ reclosePeriod(name: string, options?: PeriodScopeWithReason): Promise<PeriodRecord>;
163
+ /** Append one entry to a period's reopen log, ledger it, and invalidate the guard's view. */
164
+ private appendReopenEvent;
165
+ /** Merge each period's reopen log onto it. One adapter listing, not one per period. */
166
+ private mergeReopenState;
114
167
  /** Resolve one period within the timeline named by `options` (vault-wide when absent). */
115
168
  private findPeriod;
116
169
  /** Called by the gate bus before put/delete. `collection` selects the subject mapping (#1005). */
@@ -6,6 +6,16 @@ export declare const PERIOD_FREEZES_COLLECTION = "_period_freezes";
6
6
  export declare const PERIOD_ARCHIVES_COLLECTION = "_period_archives";
7
7
  /** Sibling of {@link PERIODS_COLLECTION} holding target-purge companions (#615). */
8
8
  export declare const PERIOD_TARGET_PURGES_COLLECTION = "_period_target_purges";
9
+ /**
10
+ * Sibling of {@link PERIODS_COLLECTION} holding reopen/reclose companions (#1022).
11
+ *
12
+ * Unlike the freeze / archive / target-purge companions — each a single,
13
+ * idempotent, once-only record — this one holds an APPEND-ONLY EVENT LIST,
14
+ * because reopening is a repeating cycle: a month can be closed, reopened,
15
+ * reclosed, and reopened again. The audit value is the sequence, not the
16
+ * latest state.
17
+ */
18
+ export declare const PERIOD_REOPENS_COLLECTION = "_period_reopens";
9
19
  /**
10
20
  * Exclusive upper bound for a period's delete-marker purge window (#604).
11
21
  * Markers carry no business date (empty body), only write-time `_ts`, so freeze