ADR-0013 -- Three-mode read/write semantics for @tenant_aware models
Status: Accepted.
Date: 2026-05-20.
Deciders: Jhoelperaltap (Owner), Tech Lead (this codebase).
Supersedes: None.
Superseded by: None.
Related: ADR-0011 (observability architecture -- dual-dispatch
pattern precedent for ENFORCEMENT_BYPASS emission); ADR-0012
(audit-observability dual-pattern -- complementary emission layer);
Phase 6 cohort handover findings #1, #10, #12, #13 from Counterbook
adopter Sprints 0-4; ADR-0015 (Counterbook adopter side -- TenantShield
upstream improvement schedule, pre-Sprint-5 pause).
Context
TenantShield v0.5.0a0 provides two access paths for
@tenant_aware models:
Model.objects.*(default): scoped manager + validating signals.Model._unscoped.*: unscoped manager, but validating signals still fire.
Cohort handover from Counterbook (first productive adopter, Sprints 0-4) surfaced three related findings:
- Finding #10 (HIGH). Legitimate admin operations
(
roll_forward_period, bulk migrations, periodic Celery tasks) need to bypass both the manager filter and the validating signals. The current workaround --with tenant_scope(bind_tenant(...)): ...-- artificially binds to a tenant context to "pretend" the caller is operating inside one. This is dishonest code: the operation is not tenant-scoped at all, yet the call site reads as if it were. Audit logs reflect a fake tenant. Reviewer mental load increases. - Finding #12 (CONTRACT).
Model._unscoped.update(...)outsidetenant_scoperaisesMissingTenantContextError. Counterintuitive because the attribute name suggests "bypass tenant filtering". Adopters reasonably expect_unscopedto be a full escape hatch. - Finding #13 (CONTRACT).
Model._unscoped.create(...)fails outsidetenant_scopebecause_validate_tenant_coherenceruns inpre_save(see ADR-0007), and runs regardless of which manager dispatched the save. The signal is per-instance, not per-manager.
These findings reveal that the current 2-mode model conflates two distinct concerns:
- What rows you see. Manager filter behavior. This is the
objectsvs_unscopeddistinction. - What rows you can mutate without tenant context. Signal
validation behavior. The current implementation gates this on
tenant_scope, not on the manager.
The two concerns happen to be coupled in the default path, but they are orthogonal in principle. ADR-0007 (event-based enforcement) explicitly states that signal validation is "the second line of defense" -- independent from manager filtering. Once stated as orthogonal, a third mode follows naturally: unscoped and unvalidated, for legitimate administrative use cases.
Decision drivers
- Counterbook KG-003 blocked. Four sprints of accumulated
technical debt on individual INSERTs because no explicit write-bypass
API exists. Each admin write currently requires either a fake
tenant_scope(Pattern A) or a per-call SQL fallback (Model.objects.using(connection).raw(...), Pattern B). Neither is sustainable as the adopter codebase grows. - Defense-in-depth correctness. The current
_unscopedbehavior is architecturally correct as a read-only path. Findings #12 and #13 are contract reinforcements, not bugs. The fix is not to weaken signal validation on_unscoped; it is to provide a separate API for legitimate write bypass. - Compliance posture. Enterprise / SOC2 audit posture requires
that signal-bypassing writes emit an observable audit event.
ENFORCEMENT_VIOLATION(raised on cross-tenant attempts) and a newENFORCEMENT_BYPASS(emitted on intentional bypass) jointly give SIEM operators a complete picture of "what went around the enforcement layer and why". - Adopter mental model clarity. A 3-mode API is teachable in a single sentence: "default, read-only escape, write escape". The current 2-mode-plus-workaround pattern is a footgun under reviewer time pressure.
Considered options
- A. (selected) Three-mode explicit API:
Model.objects,Model._unscoped,Model._unsafe_unscoped(new). - B. Keep 2-mode + document
_unscopedas read-only by design only. Leaves Finding #10 unresolved. - C. Add a
bypass_signals=Truekeyword argument to existing_unscopedoperations. Lower surface area but per-call configuration; easy to miss in code review. - D. Implement
_unsafe_unscopedas a context manager only (no manager attribute). Harder to use with bulk operations (bulk_create,bulk_update) which expect a manager.
Decision
Adopt Option A: three-mode explicit API with the semantics in the table below.
| Mode | Manager filter | Signals validate | Audit emission |
|---|---|---|---|
Model.objects (default) |
applies tenant filter | validates coherence | none (normal path) |
Model._unscoped |
no filter | still validates | none (read-only by design) |
Model._unsafe_unscoped (new) |
no filter | bypassed | ENFORCEMENT_BYPASS event |
Architectural pillars
-
_unsafe_unscopedMUST emitENFORCEMENT_BYPASSon every write operation. SeverityWARNING; payload includes the caller stack frame, the model qualname, the operation type (create/update/delete/bulk_*), and the row count affected when known. The audit emission is non-optional; adopters cannot register a sink configuration that suppresses it. -
_unsafe_unscopedis a manager attribute, not solely a context manager.Model._unsafe_unscoped.bulk_create([...])is the canonical shape. The manager attribute composes naturally with queryset chaining (.filter(),.exclude()) when a partial bypass is needed. Context-manager form is a secondary affordance layered on top, not the primary one. -
_unscopedsemantics are unchanged. Findings #12 and #13 are documented as contract reinforcements (READ-ONLY by design), not regressions. The signal validation on the create/update path of_unscopedis the second line of defense (ADR-0007) and stays. -
New
AuditEventType.ENFORCEMENT_BYPASSvalue. Joins the existing six-value enum (CONTEXT_BOUND/CONTEXT_RELEASED/POLICY_ALLOW/POLICY_DENY/ENFORCEMENT_VIOLATION/SINK_FAILURE) as a seventh, dispatched from the new manager. ENFORCEMENT_VIOLATION and ENFORCEMENT_BYPASS are jointly the "enforcement boundary events" -- one for cross-tenant violations detected, one for explicit bypass exercised. -
Adopter call-site whitelist convention. Recommended adopter policy (documented in adapter usage docs and reflected in anticipated Counterbook ADR-0025): each
_unsafe_unscopedcall site is preceded by an inline comment# ENFORCEMENT_BYPASS: <reason>and pinned by a test that asserts the audit event was emitted with the expected payload.
§Mode 3 supplement -- interaction with auto_propagate_from_parent_fk
Empirically surfaced by Counterbook KG-013 during v0.5.3-alpha
adoption: when a model is decorated
@tenant_aware(auto_propagate_from_parent_fk=True) AND a write
flows through Model._unsafe_unscoped.create(...), the
auto-propagate pre_save handler is bypassed alongside
_validate_tenant_coherence. This is consistent with the mode 3
contract documented above ("bypass ALL signal validation") but is
non-obvious to adopters who treat auto-propagate as a separate feature.
Operational consequence: _unsafe_unscoped callers MUST set
tenant_field explicitly even when the equivalent Model.objects
path would have populated it from the FK parent. The bypass scope
turns off the entire pre_save chain, not a subset of it.
This is documentation-only; no code change is needed because the
behaviour is correct per _signal_bypass_var semantics. Adopter
codebases SHOULD assert this contract in tests when both flags are
used together (Counterbook ADR-0025 + ADR-0027 codify the
recommended pattern).
Why other options rejected
- Option B leaves Counterbook KG-003 unblocked indefinitely.
Documentation alone does not provide a legitimate write path;
the workaround (fake
tenant_scope) remains the only available pattern. - Option C -- a
bypass_signals=Truekeyword on existing_unscopedmethods -- is per-call configuration. Easier to miss in code review than a separate manager attribute, and makes the surface "what does_unscopeddo?" harder to answer in one sentence. - Option D -- context manager only -- breaks the bulk-operation
ergonomics that motivate Finding #10.
bulk_createreturns instances; pairing it with awithblock adds ceremony without buying expressiveness.
Consequences
Positive
- Counterbook KG-003 unblocks.
Model._unsafe_unscoped.bulk_create([...])replaces the individual-INSERT loop inroll_forward_period, removing four sprints of accumulated technical debt. - D-CTA.0 architectural foundation. The dual-dispatch pattern
established by
_unsafe_unscoped(audit emission alongside the enforcement decision) is the same pattern D-CTA.0 will reuse for cross-tenant.update()/.delete()audit emission (Finding #1, SOC2 BLOCKER). Phase 6 trajectory benefits from a single pattern applied twice. - Findings #12 and #13 resolved via documentation.
_unscopedsemantics are stable; only the surface (docs) changes. - Teachable 3-mode API. Default / read-only escape / write escape. One sentence per mode.
Negative
- New public API surface.
_unsafe_unscopedjoins the supported contract. It cannot be removed in future minor versions without a deprecation cycle. - Audit event volume. Enterprises with frequent admin operations
emit more
ENFORCEMENT_BYPASSevents. Mitigation: events areWARNINGseverity, notERROR; adopter SIEM rules can group bymodel+operationfor noise reduction without losing per-event traceability. - Bypass is opt-in but visible. Adopters who do not enforce call-site whitelisting risk uncontrolled usage. Mitigation: this ADR documents the recommended adopter policy; adapter docs (D-DOCS.0) include a worked example.
Neutral
- Performance.
_unsafe_unscopedskips signal validation, so it is marginally faster than the scoped path. This is not marketed as a performance optimization; presenting it as one would invite misuse.
Implementation roadmap
- D-USU.0 (Phase 6 Day 1, hora 2-4). Implement
_unsafe_unscopedas a manager attribute onTenantAwareManager. Signal-bypass mechanism via a context flag set inside the manager's queryset (contextvars.ContextVarto preserve async semantics, per ADR-0009). EmitENFORCEMENT_BYPASSon every write. Behavioral tests coveringcreate/update/delete/bulk_*plus signal-bypass plus audit emission. Tagv0.5.1-alpha. - D-CTA.0 (Phase 6 Day 2-3). Cross-tenant
.update()/.delete()audit emission (Finding #1, SOC2 BLOCKER). Reuses the_unsafe_unscopeddual-dispatch pattern but emitsENFORCEMENT_VIOLATION(cross-tenant attempt detected) instead ofENFORCEMENT_BYPASS(intentional bypass exercised). Tagv0.5.2-alpha. - D-DOCS.0 (Phase 6 Day 5). Findings #12 and #13 documented as
contract reinforcements.
_unsafe_unscopedusage guidance added to adapter docs with the worked Counterbookroll_forward_periodexample and the recommended whitelist-comment convention.
Adopter coordination
Counterbook Sprint 5 (Banking) cannot initiate until TenantShield
v0.5.1-alpha is released (D-USU.0 closure). Per the amendment to
ADR-0015 (Counterbook side, 2026-05-20), the pause week was scheduled
before Sprint 5 specifically to unblock KG-003 and resolve SOC2
BLOCKER #1 ahead of Banking implementation. Banking on
TenantShield v0.6.0a0 will be architecturally cleaner: it gets a
mature dual-dispatch audit surface and a stable _unsafe_unscoped
contract from day one.
Counterbook ADR-0025 (anticipated, adopter side) will codify usage policy:
- Explicit whitelist of
_unsafe_unscopedcall sites in a project-level registry. - Inline
# ENFORCEMENT_BYPASS: <reason>comment mandatory per usage. - Per-usage test that asserts the audit event was emitted with the expected payload shape (caller frame + model + operation + row count).
References
- Finding #1 (SOC2 BLOCKER) -- silent cross-tenant
.update()/.delete()audit gap; resolved by D-CTA.0. - Finding #10 (HIGH) --
_unsafe_unscopedAPI missing; resolved by D-USU.0 per this ADR. - Finding #12 (CONTRACT) --
_unscoped.update()raisesMissingTenantContextErroroutsidetenant_scope; resolved by documentation in D-DOCS.0. - Finding #13 (CONTRACT) --
_unscoped.create()respectspre_savesignal validation; resolved by documentation in D-DOCS.0. - ADR-0007 -- Event-based enforcement for SQLAlchemy adapter (orthogonality of manager filter vs signal validation).
- ADR-0011 -- Observability architecture (event taxonomy and emission gate).
- ADR-0012 -- Audit-observability dual-pattern (the architectural
precedent
_unsafe_unscopedextends to a third dispatch site). - ADR-0015 (Counterbook side) -- TenantShield upstream improvement schedule; pre-Sprint-5 pause amendment.