cadrumo.adapters.persistence.profile._secure_enveloped_document module

Canonical encrypted persistence kernel for Envelope-wrapped profile singleton documents.

Both kernels read and write through a SecureObjectRepository supplied at construction; neither opens storage of its own.

Sibling of ProfileBareModelSecurePersistence (_secure_model_document.py). The two kernels cover two DIFFERENT on-disk wire shapes that both happen to persist “one whole singleton Pydantic document per profile bucket” — they are not interchangeable, and a namespace’s existing shape decides which kernel it enrolls in, never the other way round:

  • ProfileBareModelSecurePersistence stores the document’s own model_dump_json() bytes directly. Classification, schema version, and write timestamp live ONLY as columns on the encrypted SQL row (SecureObjectWrite); nothing is duplicated inside the JSON payload.

  • ProfileEnvelopedModelSecurePersistence (this module) wraps the document in Envelope before serialising, so classification/schema-version/written-at are duplicated BOTH as SQL-row columns AND as fields inside the stored JSON. The inner fields are re-checked against the consumer’s expectation on every load, as defense-in-depth against a row whose column metadata and embedded payload metadata have drifted apart.

Twelve profile repositories predate either kernel and hand-rolled the Envelope-wrapped shape independently: this module is the shared home for that shape now, so a namespace already carrying Envelope-wrapped rows on disk can enroll in a kernel WITHOUT a wire-format break. Picking ProfileBareModelSecurePersistence for one of those namespaces would silently change what bytes get written and, symmetrically, would fail to parse the Envelope-wrapped rows already on disk for that namespace — a real behaviour change, not a refactor. A brand-new namespace with no format to preserve should default to the leaner ProfileBareModelSecurePersistence instead of adopting this one; this module exists to converge EXISTING Envelope-wrapped repositories on one implementation, not to grow the Envelope-wrapped shape’s footprint.

class ProfileEnvelopedModelSecurePersistence(*, objects, definition, model_type, empty_document, serialization_context=None)[source]

Bases: Generic

Persist one Envelope-wrapped strict Pydantic document through a governed secure object.

The namespace definition remains the authority for object key, sensitivity, and schema version. save delegates to to_secure_object_write() so an ordinary singleton save and a caller-composed co-commit write build the identical Envelope bytes and cannot drift apart.

Parameters:
property object_key: str

Return the registry-owned singleton object key.

property namespace: str

Return the registry-owned namespace value.

logical_path(marker)[source]

Return the registry-defined logical marker for one repository surface.

Return type:

Path

Parameters:

marker (str)

exists()[source]

Report whether the singleton object has been persisted.

Checks the encrypted store for an object under this namespace and key without decrypting or validating it, so True attests to presence only, not integrity.

Return type:

bool

load()[source]

Load, decrypt, and unwrap the persisted document, or return its declared empty form.

The outer SQL-row classification/schema-version columns are checked by the underlying expected_class/max_supported_version load arguments; the inner Envelope fields are re-checked independently as defense-in-depth against a row whose embedded payload metadata has drifted from its own columns.

Raises:
  • ClassificationError – The inner envelope’s classification disagrees with this repository’s declared sensitivity.

  • EnvelopeVersionError – The inner envelope’s schema version is not the consumer’s current version.

Return type:

DocumentT

load_revisioned(*, validation_context=None)[source]

Return the stored document and the revision id it was read at.

The public read for a caller composing a GUARDED co-commit: it cannot use mutate(), whose write commits on its own, but it needs the same revision to carry on its write. Absent rows report the ABSENT_SECURE_OBJECT_REVISION_ID sentinel, so the first writer of a singleton is guarded exactly like every later one.

Return type:

tuple[DocumentT, str]

Parameters:

validation_context (Mapping[str, object] | None)

to_secure_object_write(document, *, expected_revision_id=None)[source]

Prepare the Envelope-wrapped encrypted-SQL upsert without committing it.

Callers that need to co-commit this document with sibling secure objects pass the returned value into their existing save_many transaction. The returned write carries the identical Envelope bytes save() would persist directly.

expected_revision_id is the compare-and-swap half, and without it a co-commit carries the same lost update mutate() exists to prevent: these documents are SINGLETONS, so composing from an unguarded read writes back the whole document and discards any entry another caller added in between. Pass the revision load_revisioned() reported and re-run the composition if the substrate refuses the write. It stays optional because a caller writing a document it did not derive from a read has no revision to assert – but a caller that DID read one and omits it is choosing the silent discard.

Return type:

SecureObjectWrite

Parameters:
  • document (DocumentT)

  • expected_revision_id (str | None)

save(document)[source]

Encrypt, wrap in an Envelope, and save one document.

Return type:

None

Parameters:

document (DocumentT)

mutate(mutation, *, attempts=4)[source]

Apply mutation to the stored document as one guarded unit of work.

These documents are SINGLETONS: every entry lives in one encrypted row, so “add one entry” is really read-whole-document, rebuild, write whole document. Performed unguarded, two callers adding DIFFERENT entries both read the same document and the later write silently discards the earlier caller’s entry – a lost update, and not a conflict any uniqueness check would notice, because the two entries never met. On a financial catalogue that is a dropped invoice, which under-declares.

The write carries the revision the document was READ at, so the substrate refuses it if the row moved in between; the mutation is then re-applied to the newly-current document. mutation is therefore called once per attempt and MUST be a pure function of the document it is handed – it must not close over a value derived from an earlier read, or the retry re-applies a stale decision.

Parameters:
  • mutation (Callable[[DocumentT], DocumentT]) – Builds the next document from the current one. May raise to refuse the mutation outright (a duplicate-identifier check, for instance); the refusal propagates unretried, since a conflict is what the retry exists for and a refusal is not one.

  • attempts (int) – Maximum reads of the current document. Exceeding them raises the substrate’s conflict error rather than looping.

Return type:

DocumentT

Returns:

The document as written.

Raises:

SecureObjectRevisionConflictError – Contention persisted across every attempt.