Persistence Compiler / IRI-Patterns Sync — Plan

Unit ID: persistence-compiler-iri-sync Status: Complete — all six slices done, 774/774 tests passing, human-validated 2026-09-25. See persistence-compiler-iri-sync.md Sketch (gap analysis): persistence-compiler-iri-sync.md Governing ADRs: ADR-A78 (persistence substrate), ADR-A79 (compiler toolchain), ADR-A82 (framework-neutral identity pattern selection) New ADR required for this plan itself: No. Every dimension this plan wires already exists, ratified, in ontology/persistence/spec/persistence.ttl. This is compiler catch-up, not a new design.

Scope

Bring tools/persistence back into sync with the dal: vocabulary as it exists after commit c276afb, per the gap analysis. Six slices, sequenced by severity and dependency. Each follows copilot-instructions’ mandatory slice shape: code, a Validation Pack at docs/developer/validation/persistence-compiler-iri-sync-<slice>.md, a traceability update to docs/developer/INDEX.md, and doc deltas in the same slice (not deferred).

Non-weakening rule applies: no slice may loosen or delete an existing test in tools/persistence/tests (471 passing on 2026-09-23, after Slice 1 and the iri-patterns-post-3866b21-remediation template alignment). Every slice adds tests. A test may be rewritten only where a documented change of contract makes its assertion obsolete, and the slice’s VP names it.

Slice 1 — Dataset-level epoch guard (G1)

Why first: the only finding that is a live correctness problem, not a coverage gap. The compiler generates the pattern the spec now calls unsafe, for every deployment, today.

Slice 2 — Ordering/receipt/concurrency/aggregate-boundary extension properties, and meta-topology sharding (G5, G6)

Mode: autonomous (granted 2026-09-23).

Decisions (agreed 2026-09-23)

  1. Each new property is its own resolved dimension. The resolver today reads extra properties only from the profile node that wins the dimension’s primary value, so a property declared on a separate or lower-priority profile node is dropped silently, and extras never reach the compiled profile. Every Slice 2 property is therefore added as a dimension of its own, resolved by the existing precedence algorithm (priority, reasoning-aware tie break, ProfileAmbiguityError) and emitted as a dal:ResolvedDimension. Candidates are nodes typed with the property’s profile class, or dal:DataAccessProfile nodes, that declare the property. Literal-valued dimensions are emitted with a new dal:resolvedLiteral datatype property, since dal:resolvedValue is an object property.
  2. Baseline defaults, following Slice 1’s precedent that absence of configuration is an explicit, documented value: firstWrite → dal:AbsentRow (today’s behaviour), etagForm → dal:StrongEtag, etagRepresentation → dal:SingleRepresentation, deadlockPolicy → dal:EngineDetectAndRetry, contiguityCheckMode → dal:BlockingContiguityCheck, retentionMode → dal:PrefixOnlyRetention. No default for globalReadStrategy, lagWindowMillis, asOfFloorSource, the three shard counts or registryGraph.
  3. Shard counts are resolved but not yet honoured. Every template writes to one txn, keys and log-bucket graph. A declared dal:txnShards, dal:logShards or dal:keyShards greater than 1 raises a ShardingNotHonoured warning rather than being ignored silently.

Dimensions added

Dimension Profile class Property Kind
firstWrite dal:AggregateBoundaryProfile dal:firstWrite object
etagForm, etagRepresentation, deadlockPolicy dal:ConcurrencyProfile same names object
globalReadStrategy, contiguityCheckMode dal:OrderingProfile same names object
lagWindowMillis dal:OrderingProfile dal:lagWindowMillis literal
retentionMode dal:ReceiptProfile dal:retentionMode object
asOfFloorSource dal:ReceiptProfile dal:asOfFloorSource literal
txnShards, logShards, keyShards, registryGraph dal:MetaTopologyProfile same names literal

Behaviour

Checks (Python mirrors, on resolved values)

Check Severity Mirrors
dal:etagForm dal:WeakEtag with dal:Optimistic WARNING dal:WeakEtagCasWarningShape
dal:globalReadStrategy dal:NoGlobalRead WARNING dal:NoGlobalReadWarningShape
a dal:datasetTierModel declared with no dal:globalReadStrategy WARNING new, decision 2
dal:contiguityCheckMode dal:AdvisoryContiguityCheck WARNING dal:AdvisoryContiguityWarningShape
dal:retentionMode dal:BucketAnyRetention with a dal:asOfFloorSource ERROR (CrossAxisViolation) dal:AsOfFloorRetentionCompatibilityShape
dal:LagWindowRead without a positive dal:lagWindowMillis ERROR (CrossAxisViolation) new dal:LagWindowRequiredShape
dal:deadlockPolicy dal:SortedAcquisition with dal:Optimistic or dal:AppendOnly WARNING new: sorted acquisition needs multi-request or external-lock strategies (guide §19.6)
a shard count greater than 1 WARNING (ShardingNotHonoured) new, decision 3

The SHACL shapes check one profile node at a time, and the Python checks check resolved values across nodes. Where they differ, for example a weak ETag and optimistic concurrency declared on two different nodes, the Python check is authoritative and the SHACL shape is the same-node subset.

Tests

A positive and negative pair per check. Per-property resolution from a separate profile node and from a lower-priority node. Baseline defaults. Literal dimension emission. dal:PreCreatedRow excluding create-if-absent and emitting bootstrap-version-row. The request-time slot contract. registryGraph binding. Determinism over the new dimensions.

Slice 3 — Identity minting profile resolution (G2)

Mode: autonomous (granted 2026-09-23).

Decisions (agreed 2026-09-23)

  1. Role-qualified dimensions. Target stays (class, deployment). Each dal:ResourceRole is its own dimension, named identity:<RoleLocalName> (for example identity:EntityRole), resolved per target by the existing precedence algorithm among dal:IdentityProfile nodes that name that role. The winning dal:IdentityProfile node wins as a unit: its dal:digestScheme, dal:occurrenceNamespaceDerivation, dal:eventIdentityStrategy, dal:uniquenessWitnessRequired and dal:namingAuthority come from the same node, because they only have meaning next to its strategy. This differs deliberately from Slice 2’s per-property resolution. Only roles that some profile declares are emitted, with no baseline default (ADR-A82: the framework does not pick an identity pattern).
  2. Resolve, check, emit. Slice 3 generates no SPARQL. Resolved identity dimensions go into the compiled profile (dal:resolvedValue the strategy, dal:wonBy the profile node) for the caller that mints IRIs (epic P2.1.5). Minting stays in the application, where the normalization pipeline and HMAC secret live.
  3. Checks (Python, on resolved values):
Check Severity SHACL
dal:DerivedHashIdentity or dal:ContentAddressedIdentity without a digest scheme giving function, width and encoding ERROR existing dal:DigestSchemeRequiredShape
a digest scheme whose dal:digestWidthBits is not a positive multiple of 8, or whose dal:digestEncoding is not lowercase-hex, base32 or base64url ERROR new dal:DigestSchemeWellFormedShape
dal:PositionDerivedEvent without dal:uniquenessWitnessRequired true ERROR existing dal:UniquenessWitnessRequiredShape
dal:PositionDerivedEvent without dal:occurrenceNamespaceDerivation ERROR new dal:OccurrenceNamespaceDerivationRequiredShape
dal:EntityRole or dal:AggregateRootRole resolved to dal:SurrogateClaimedIdentity with no dal:UniquenessConstraint on the target ERROR Python only (a resolved cross-profile join)
dal:PositionDerivedEvent while the resolved epoch configuration is dal:StoreLocalEpoch or dal:RowLevelGuardOnly (the baseline included) WARNING Python only

Tests

One fixture per dal:ResourceRole resolving independently for one class. A namespace-wide profile overridden per role by a class-level one. Equal-priority ambiguity within one role. Whole-node resolution (a digest scheme never mixes across nodes). No default for an undeclared role. Emission of identity: dimensions. A positive and negative case per check. Worked example 4’s fixture (identity-epoch-privacy-profile.ttl) is authored here, since its identity and epoch parts compile now; its privacy part is exercised by Slice 4.

Slice 4 — Privacy/erasure profile and cross-profile compatibility (G3 partial, G4)

Slice 5 — Uniqueness: onViolation branching, mergeRelation, ClaimScheme rotation (G7)

Resolved 2026-09-25 (Decision 1): the guarded write’s “distinct generated operations” above turned out to belong in Chapter 7.5 (P7, the reconciler), not Chapter 6 (P1/P2, which only ever describes the Reject shape). Option A: key-claim-write/key-claim-write-dual never branch on onViolation; the policy instead selects one reconciler operation per constraint (key-claim-duplicate-audit / key-claim-merge-rewrite / key-claim-quarantine). See the status record’s “Slice 5 decisions” and the Slice 5 VP.

Also folded into this slice (human decision, 2026-09-25): the registry-token digest-scheme relaxation deferred from Slice 3/4 (status record “Next steps” item 4, found in identity-minting M3) — dal:DigestSchemeRequiredShape and its Python mirror now exempt dal:PositionDerivedEvent + dal:RegistryTokenDerivation only.

Slice 6 — Documentation close-out

Done early, on 2026-09-23, during a cross-document disposition pass: both status records above and the INDEX already point at this unit’s live progress, and the README “Known limitations” is current as of Slice 2. Slice 6 remains the final pass once Slices 3–5 land.

Dependencies and sequencing

Slice 1 (epoch guard, urgent) ──┐
Slice 2 (extras, low risk)     ─┼─► independent, any order, can run in parallel
Slice 6 (docs close-out)        │   waits on whichever slices actually land
                                 │
Slice 3 (identity, blocked) ────┴─► blocked on human resolution-model decision
Slice 4 (privacy)                   independent of 1/2/3, can start any time
Slice 5 (uniqueness/claim)          independent of 1/2/3/4, can start any time

Slice 1 should land first given its severity, but nothing structurally blocks starting Slices 2, 4, or 5 in parallel. Slice 3 is the only slice gated on a decision this plan does not make.

Human decision required before Slice 3

Does resolving dal:IdentityProfile require extending the Target model with a resourceRole axis, or is there a better-fitting mechanism? See the sketch’s G2 finding for the concrete scenario that forces the question (one aggregate class needing different identity strategies for its own entity identity versus its event occurrences’ identity, simultaneously).

Resolved 2026-09-23: neither a Target axis nor a new scope kind. Each role is its own resolved dimension (Slice 3, decision 1).

Validation approach

Per copilot-instructions’ human validation gate: each slice’s VP names its positive and negative cases, the single mise run check:persistence command, and at least one adversarial probe (deliberately break the new check, show it fails, per the pattern already established in docs/developer/validation/persistence-substrate-and-compiler.md’s two adversarial probes). No slice is signed off without one.