Found by the tenant-isolation adversary seat reviewing #562 / PR #671 (2026-09-02). Latent — no such property exists today — but both layers are fail-OPEN for it, and the guard that should catch it mirrors the same predicate.
The shape
Both write-side layers key on AccountId being a non-nullable Guid:
So a future tenant-owned entity mapped with public Guid? AccountId (or a strongly-typed id with a converter) reopens exactly the #562 shape: a detached Update(stub)/Remove(stub) with AccountId = null emits UPDATE … WHERE "Id" = @id alone and the database relabels or deletes another farm's row — with every test green.
PR #671's fix round tightened AccountIdConcurrencyTokenModelTests so that the guard now selects carriers by name and non-key only and asserts ClrType == typeof(Guid) — a nullable AccountId reddens the model test instead of vanishing from it. That is a guard, not a mechanism.
Close it by construction
- Make the walk fail closed: an
AccountId property whose CLR type is not exactly Guid throws at model build (InvalidOperationException naming the entity), so a mis-typed AccountId fails every boot and every test rather than passing unguarded.
- Make the interceptor fail closed on the same condition: a non-
Guid AccountId value on an Added/Modified/Deleted entry under a resolved tenant throws TenantWriteMismatchException (or a dedicated exception) rather than returning.
Verify
A model-only test that maps a throwaway entity with Guid? AccountId into a ModelBuilder and asserts the walk throws; a mutation that deletes the throw must redden it. For the interceptor, a stub whose AccountId property is boxed as null on a Modified entry must be refused.
Found by the tenant-isolation adversary seat reviewing #562 / PR #671 (2026-09-02). Latent — no such property exists today — but both layers are fail-OPEN for it, and the guard that should catch it mirrors the same predicate.
The shape
Both write-side layers key on
AccountIdbeing a non-nullableGuid:TenantStampInterceptor.Verify/StampOrVerifyAdded:if (value is not Guid accountId) return;— anull(nullableGuid?) or value-convertedAccountIdis silently not checked.AppDbContext.OnModelCreating:if (accountId is null || accountId.ClrType != typeof(Guid)) continue;— the same property gets no concurrency token.So a future tenant-owned entity mapped with
public Guid? AccountId(or a strongly-typed id with a converter) reopens exactly the #562 shape: a detachedUpdate(stub)/Remove(stub)withAccountId = nullemitsUPDATE … WHERE "Id" = @idalone and the database relabels or deletes another farm's row — with every test green.PR #671's fix round tightened
AccountIdConcurrencyTokenModelTestsso that the guard now selects carriers by name and non-key only and assertsClrType == typeof(Guid)— a nullableAccountIdreddens the model test instead of vanishing from it. That is a guard, not a mechanism.Close it by construction
AccountIdproperty whose CLR type is not exactlyGuidthrows at model build (InvalidOperationExceptionnaming the entity), so a mis-typedAccountIdfails every boot and every test rather than passing unguarded.GuidAccountIdvalue on anAdded/Modified/Deletedentry under a resolved tenant throwsTenantWriteMismatchException(or a dedicated exception) rather than returning.Verify
A model-only test that maps a throwaway entity with
Guid? AccountIdinto aModelBuilderand asserts the walk throws; a mutation that deletes the throw must redden it. For the interceptor, a stub whoseAccountIdproperty is boxed asnullon a Modified entry must be refused.