aeat.application.user_profile._profile_repository module

The single, sole writer of a logical profile’s physical stores.

A logical profile fragments across a bucket directory, a plaintext BucketManifest, an encrypted UserProfileRecord row in a per-bucket SQLite database, and an append-only BucketEventHistoryRepository. Before this repository, every CLI handler and application service wrote whichever stores it remembered; consistency was by convention and a failure mid-sequence left a half-live profile.

ProfileRepository makes “touch every store” structural. ProfileRepository.create() and ProfileRepository.delete() are cross-store units of work: the filesystem directory + manifest are staged first (a directory with no secure-object row is detectable, reclaimable garbage), the encrypted record commits next inside the SQLite transaction, the BucketPointer moves last. On any failure the staged filesystem state is rolled back. ProfileRepository.load() runs verify_profile_integrity() so a profile whose stores have drifted surfaces the drift instead of being served silently.

Application orchestration (register_active_profile(), remove_active_profile(), select_profile()) and the CLI config profile verbs delegate their store writes here; there is exactly one implementation of the cross-store profile write.

See also

ProfileLifecycleService

Bucket-local record and lifecycle-event service composed by this repository.

read_profile_bucket()

Manifest scan used by label lookup and live-profile surfaces.

ProfileAggregate

In-memory cross-store profile aggregate returned by this repository.

TAX_ID_FACT_PATH

Profile-fact path carrying the taxpayer’s Spanish NIF / NIE / CIF.

class ProfileSummary(**data)[source]

Bases: BaseModel

A typed one-row summary of a registered profile.

Returned by ProfileRepository.list(); carries only the plaintext-manifest projection (UUID, label, lifecycle status) so a listing never needs to unlock an encrypted bucket.

Parameters:
  • profile_id (Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=36, pattern=^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$, ascii_only=None)])

  • label (str)

  • status (UserProfileStatus)

profile_id: ProfileId
label: str
status: UserProfileStatus
class ProfileRepository(*, root=None, secure_objects=None, schema=None)[source]

Bases: object

The sole writer of a logical profile’s cross-store physical state.

The repository composes the existing lower-level pieces — the secure-record UserProfileLifecycleRepository, provision_bucket_directory(), the BucketManifest IO helpers, and the active-profile BucketPointer IO. It is the single place those writes happen.

Parameters:
property root: Path
create(*, label, facts=(), profile_id=None, enforce_unique_tax_id=True, routing_profile_id=None)[source]

Create a new profile as a cross-store unit of work.

enforce_unique_tax_id (default True) refuses a create whose tax id is already carried by a live profile — a fresh config profile create must not split one taxpayer’s filing history across two profiles. config profile duplicate and config profile import legitimately reproduce an existing profile’s tax id and pass False.

Mints a fresh UUID identity (unless profile_id is supplied for a caller that already minted one), then performs the all-or-nothing write — every store write, the active-profile pointer included, is inside this single unit of work:

  1. Stage the bucket directory + plaintext manifest. A directory with no manifest is detectable, reclaimable garbage — a rolled-back create; the manifest write is the point a profile becomes registered.

  2. Write the active-profile pointer. The per-bucket SQLAlchemy engine resolves its URL from the pointer chain, so the pointer must precede the encrypted-record write.

  3. Commit the encrypted UserProfileRecord (the SQLite transaction).

The pre-create active-profile pointer is captured here, before the first store write, so a failure at any step rolls back the staged directory + manifest AND restores the pointer to exactly its pre-create state. No caller writes the pointer; the missing_profile_record torn state (pointer aimed at a profile with no record) is unreachable because the pointer write and the record write are one unit of work.

Parameters:
  • label (str) – Operator-visible display name for the new profile.

  • facts (Sequence[UserProfileFact]) – Initial profile fact sequence. Defaults to empty.

  • profile_id (str | None) – Optional explicit UUID. When None, a fresh UUID is minted.

  • enforce_unique_tax_id (bool) – When True (default), refuses a create whose tax id is already carried by a live profile.

  • routing_profile_id (str | None) – Optional routing assertion; must match profile_id when supplied.

Return type:

ProfileAggregate

Returns:

The assembled ProfileAggregate.

Raises:
  • ProfileNotFoundError – If the profile already carries a manifest (it is already a registered profile).

  • AeatError – If an error occurs during the all-or-nothing write.

  • OSError – If a filesystem error occurs during directory staging.

  • UserProfileValidationError – If the routing profile id does not match the resolved profile id.

  • ValidationError – If the encrypted record fails schema validation.

load(profile_id)[source]

Assemble the aggregate from every store; verify integrity.

Reads the plaintext manifest and the encrypted record, runs verify_profile_integrity() to confirm the directory, the manifest, and the record all agree on the UUID, the lifecycle status, and the display label, then builds the ProfileAggregate.

Parameters:

profile_id (str) – The UUID of the profile to load.

Return type:

ProfileAggregate

Returns:

The assembled ProfileAggregate for the profile.

Raises:

ProfileNotFoundError – If the bucket directory or manifest is absent.

save(aggregate)[source]

Persist changes to the aggregate across its physical stores.

The manifest projection (label, kdf_params, recovery_enrolled) and the encrypted record are both re-written so the two label copies never drift. The active-profile pointer is not touched — selecting a profile is a distinct operation.

Return type:

None

Parameters:

aggregate (ProfileAggregate)

rename(profile_id, *, new_label)[source]

Change a profile’s operator-visible label across its stores.

Profile identity is an immutable UUID, so a rename is a pure metadata edit: only the label moves, in the two stores that hold a copy of it - the encrypted UserProfileRecord display_name and the plaintext manifest label. There is no directory move and no re-key.

Both writes happen here, inside the sole writer: the lifecycle service updates the record and emits PROFILE_RENAMED, then the manifest label projection is rewritten. load runs the cross-store integrity check first, so a drifted profile raises ProfileIntegrityError rather than being relabelled in a torn state.

Parameters:
  • profile_id (str) – The UUID of the profile to rename.

  • new_label (str) – The new operator-visible display name.

Return type:

ProfileAggregate

Returns:

The updated ProfileAggregate with the new label.

Raises:
delete(profile_id)[source]

Tombstone a profile via its lifecycle status.

delete is a soft removal: the encrypted record is tombstoned and re-saved; the bucket directory and manifest are left intact so audit and history reads still resolve.

Three stores mutate, in an order chosen so every torn intermediate fails closed:

  1. The active-profile pointer is cleared first. A crash here leaves a still-live record with no active pointer — benign, the operator simply re-selects.

  2. The plaintext manifest status is mirrored to tombstoned BEFORE the record tombstone. A crash between this write and the record tombstone leaves a manifest saying tombstoned over a still-live record. That is the safe direction: the profile immediately drops off every live surface (list / switch / name-uniqueness all read the manifest), and the record is still loadable for repair. The reverse order — record first — would leave a manifest saying active over a tombstoned record, re-opening the leak.

  3. The encrypted record is tombstoned last.

load runs the cross-store integrity check, which compares the manifest status against the record status, so a profile left in the step-2/step-3 drift state by a crash raises ProfileIntegrityError on the next load - reclaiming a drifted profile is the repair surface’s domain.

Parameters:

profile_id (str) – The UUID of the profile to tombstone.

Return type:

ProfileAggregate

Returns:

The updated ProfileAggregate with tombstoned status.

reactivate(profile_id)[source]

Restore a tombstoned profile to active status; symmetric inverse of delete().

reactivate never opened by delete’s counterpart before: it is a pure lifecycle-status flip back to active. The bucket directory, manifest, and encrypted record were all left intact by the soft tombstone, so no directory or key-schedule provisioning happens here.

The write order is the mirror image of delete()’s own crash-safety reasoning: the encrypted record is reactivated FIRST, the plaintext manifest mirror SECOND. A crash between the two leaves the manifest saying tombstoned over a now-active record — the safe direction, because the manifest scan (list / switch / name-uniqueness) still excludes the profile from every live surface until the manifest write lands. The reverse order would leave the manifest claiming active over a record the encrypted-store lifecycle service still refuses to reactivate-confirm, re-opening the same exposure window delete() avoids.

Parameters:

profile_id (str) – The UUID of the profile to reactivate.

Return type:

ProfileAggregate

Returns:

The updated ProfileAggregate with active status.

Raises:

ProfileNotFoundError – If the profile is not currently tombstoned, or if the bucket directory or manifest is absent.

select(profile_id)[source]

Make a registered profile the active one.

Loads and integrity-checks the aggregate — a profile whose stores have drifted is never selected — then writes the active-profile pointer. The pointer is the only store this mutates; identity and lifecycle metadata are untouched.

A tombstoned profile is not a selectable profile: delete is a soft removal that retains the bucket for audit, but the profile has left the live surface. Selecting it is refused with the same ProfileNotFoundError class as an unknown profile so the operator cannot unknowingly work inside a deleted profile.

Parameters:

profile_id (str) – The UUID of the profile to activate.

Return type:

ProfileAggregate

Returns:

The ProfileAggregate for the newly active profile.

Raises:

ProfileNotFoundError – If the profile is not registered, or is registered but tombstoned.

list()[source]

Return a typed summary for every registered profile.

Scans <root>/buckets/*/manifest.toml — never unlocks an encrypted bucket. The lifecycle status reported is the manifest status mirror, kept in lockstep with the encrypted record by every ProfileRepository write. Tombstoned profiles are included so callers that need the full inventory (repair, audit) see them; live-surface callers filter on status.

Each element is a ProfileSummary; live label lookups use read_profile_bucket().

Return type:

Sequence[ProfileSummary]