CultLib/docs/research/read-only-record-signal-prior-art.md
Meta Vens 639782c5d6 Research: prior art for signalling a read-only record before the write is refused
Eyes pass for variants❓read-only-record-signal. ORMs, embedded
stores, serializers, file systems and object stores; signal shape and
time-of-check pitfalls for each, with evidence pointers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:34:01 +02:00

19 KiB

Read-Only Record Signal: Prior Art Survey

Facts file for Eureka question variants:question:read-only-record-signal: how a developer learns that a held record is read-only before a write is refused. Its options are (a) no new API, (b) a live query such as CultCache.IsWritable(key), and (c) a list beside ForeignRecords. Eyes pass, 2026-10-03. Facts and evidence pointers only; no recommendation.

Verification marks:

  • [fetched] the claim was read on the cited page or source during this pass.
  • [search] the claim came from a search-result summary of the cited page, not a full read.
  • [memory] recalled from the cited source, not re-read in this pass. Treat it as unverified.

Signal shapes used below:

  • property: a flag or state on the loaded object itself.
  • query: a call on the store, session or registry that answers at call time.
  • list: an enumeration of affected items, captured or computed.
  • type: the read-only status is a distinct static type.
  • exception only: nothing to ask; the write is the first signal.

0. Our design, for comparison

Source: branch hands/variants-c2a (head e9a66876), src/GameCult.Caching/CultCache.cs.

  • CultDocumentRegistry.Declares(string schemaId) (line 738) is internal. Its comment: "The one answer to whether a cache may write, relabel or remove a record stored under this id ... Read live, so a type registered later that declares the id claims its records at their next write." [fetched]
  • GetRequired<T>() (line 631) registers lazily, so the set of declared ids can grow after load. [fetched]
  • The backing store already exposes ForeignRecords (line 3421), an IReadOnlyList<CultForeignRecord> "the last load found and carries untouched", set at load (SetForeignRecords, line 3599). CultForeignRecord (line 162) carries Key, SchemaId, SchemaName, StoredAt. [fetched]
  • The store also has a store-level public bool IsReadOnly { get; } (line 3419), fixed at construction. [fetched]
  • Main (162bd1fc) exposes LastSchemaMigrationReports (CultCache.cs:3136), whose entries carry no record key (per the question text). [fetched for the property; the no-key claim is the question's]

1. ORMs

EF Core: no-tracking queries and keyless entity types

  • Signal shape: query on the context, plus a model query. context.Entry(e).State returns an EntityState; Detached means "not being tracked by the DbContext", and its "Action on SaveChanges" is none. (Change Tracking, "Entity states" table) [fetched]
  • No-tracking results (AsNoTracking, or QueryTrackingBehavior.NoTracking on the context) are plain instances; changes to them are not persisted by SaveChanges. (Tracking vs. No-Tracking, "No-tracking queries") [fetched]
  • Keyless entity types "are never tracked for changes in the DbContext and therefore are never inserted, updated or deleted"; their mapped object is "treated as a read-only query source". (Keyless Entity Types, "characteristics", "Mapping to database objects") [fetched]
  • Attempting to Attach/Update a keyless instance throws InvalidOperationException with CoreStrings.KeylessTypeTracked: "Unable to track an instance of type '{type}' because it does not have a primary key." (CoreStrings.KeylessTypeTracked) [search]
  • The model answers keylessness: IReadOnlyEntityType.IsKeyless / FindPrimaryKey() == null. [memory]
  • Pitfall: modifying a no-tracking instance and calling SaveChanges is silent; nothing is written and nothing is thrown. ChangeTracker.HasChanges() returns false (Change Tracking page, closing tip). [fetched]

Hibernate: @Immutable and read-only entities

  • Signal shape: query on the session. Session.isReadOnly(Object entityOrProxy): "Is the specified entity or proxy read-only?"; isDefaultReadOnly() for the session default; setReadOnly(Object, boolean) flips one entity. (Session javadoc 6.4) [fetched]
  • Read-only can be set per session (setDefaultReadOnly), per query (Query.setReadOnly(true), before execution) or per entity. (Hibernate 4.3 manual ch. 12, 12.1.2-12.1.3) [fetched]
  • Write behaviour: changes to read-only entities are ignored at flush, no exception ("Hibernate will not update simple properties or updatable single-ended associations"); "A read-only entity can be deleted." (ch. 12.1) [fetched]
  • @Immutable: "Changes made in memory to the state of an immutable entity are never synchronized to the database. The changes are ignored, with no exception thrown." Immutable collections, by contrast, throw HibernateException on add or remove. (@Immutable javadoc 6.2) [fetched]
  • Bulk HQL updates of immutable entities: setting hibernate.query.immutable_entity_update_query_handling_mode chooses warning (default) or exception. (QuerySettings 6.6) [search]
  • Pitfall: the default for entity changes is silent loss; the read-only state is per session, so the same row is writable in another session.

ActiveRecord: readonly?

  • Signal shape: property on the record. readonly? "returns true if the record is marked as read-only"; readonly! marks it. (ActiveRecord::Core) [fetched]
  • Relation-level: User.readonly marks every loaded record; users.first.save # => ActiveRecord::ReadOnlyRecord: User is marked as readonly. (QueryMethods#readonly) [fetched]
  • Write behaviour: save, update or destroy raise ActiveRecord::ReadOnlyRecord. [fetched]
  • The flag is set when the record is loaded and lives on the in-memory object; it is not re-derived from the database or the model at write time. [memory]

2. Document stores and embedded databases

Realm (Kotlin SDK): frozen objects

  • Signal shape: property functions on the object. BaseRealmObject.isFrozen(): "A frozen object is tied to a specific version of the data in the realm and fields retrieved from this object instance will not update"; also isManaged() and isValid() ("the Realm is open and the underlying object has not been deleted"). (BaseRealmObjectExt.kt L41-71) [fetched]
  • Recovery path: MutableRealm.findLatest(obj) returns a live, writable copy inside a write block, or null "if the object has been deleted". (MutableRealm.kt L50-56) [fetched]
  • Write behaviour: writing to a frozen object throws. Early SDKs threw a generic "Wrong transactional state ..." message; issue #297 asked for "Frozen objects cannot be modified" plus a pointer to findLatest. (realm-kotlin#297) [fetched]
  • Pitfall: isFrozen() is stable for a given instance, but isValid() is live and can flip when another writer deletes the object.

Couchbase Lite

  • Signal shape: type. "By default, a document is immutable when it is read from the database. Use the Document.ToMutable() to create an updatable instance." Document versus MutableDocument makes the distinction static. (Couchbase Lite C# Documents, "Mutability") [fetched]
  • Write conflicts with other writers are handled at save by concurrency control (last-write-wins or fail-on-conflict) or a conflict handler. [memory]

LiteDB

  • Signal shape: store-level option only. Connection string ReadOnly ("Open datafile in read-only mode"). No per-document read-only state found. (LiteDB connection string) [fetched]

SQLite

  • Signal shape: store query. sqlite3_db_readonly(D,N) "returns 1 if the database N of connection D is read-only, 0 if it is read/write, or -1 if N is not the name of a database". (sqlite3_db_readonly) [fetched]
  • sqlite3_stmt_readonly() answers whether a prepared statement writes. [memory]

Core Data

  • Signal shape: store property plus an open option. NSPersistentStore.isReadOnly: "A Boolean value that indicates whether the persistent store is read-only" (get/set); NSReadOnlyPersistentStoreOption: "A flag that indicates whether a store is treated as read-only", default false. (isReadOnly, NSReadOnlyPersistentStoreOption) [fetched]
  • Granularity: store, not object. An object's store is reachable through its objectID.persistentStore. [memory]

3. Serializers and schema evolution: unknown or foreign data

Protocol Buffers

  • Unknown fields: "Proto3 messages preserve unknown fields and include them during parsing and in the serialized output." They are lost on JSON serialization or when copying field by field into a new message. (proto3 guide, Unknown Fields) [fetched]
  • Signal shape for unknown fields: property/list on the message, getUnknownFields() returning an UnknownFieldSet (Java). [memory]
  • Any with a foreign type: query any.is(Foo.class) returns a boolean; unpack(Class) throws InvalidProtocolBufferException. (Any javadoc) [fetched]
  • Granularity: fields inside a known message, or one embedded payload; no ownership or write-permission concept.

Apache Avro

  • Writer fields absent from the reader schema are dropped: "the writer's value for that field is ignored". A union branch the reader lacks: "if none match, an error is signalled". (Avro 1.11.1 spec, Schema Resolution) [fetched]
  • Signal shape: exception only for unresolvable data; nothing marks a record as carrying dropped fields.

Unity SerializeReference: missing types (closest found to CultCache)

  • A host asset holds managed references whose recorded type (assembly, namespace, class) is not in the current project.
  • Signal shape: query returning a bool and a list. SerializationUtility.HasManagedReferencesWithMissingTypes(Object obj): "returns true if one or more managed references is missing its type." GetManagedReferencesWithMissingTypes returns the ManagedReferenceMissingType entries: assemblyName, className, namespaceName, referenceId, serializedData. (HasManagedReferencesWithMissingTypes, ManagedReferenceMissingType) [fetched]
  • Behaviour: the field stays null, the persisted state is retained on resave, and if the type returns later, "the state of the Managed Reference object can be recovered". ClearManagedReferenceWithMissingType removes one deliberately. (GetManagedReferencesWithMissingTypes) [search]
  • Both calls are made on demand against a host object; the docs do not say the list is cached at load. A sibling PrefabUtility.HasManagedReferencesWithMissingTypes exists for prefab instances. [search]

.NET DataContractSerializer: IExtensibleDataObject

  • Unknown members round-trip through an opaque ExtensionDataObject property on the object. It cannot be inspected, so it is a carrier, not a signal. [memory]

4. File systems and object stores

POSIX access(2) and TOCTOU

  • Signal shape: query on the path. access(path, W_OK) fails with EROFS on a read-only file system. The check uses the real UID/GID, "rather than the effective IDs as is done when actually attempting an operation". (access(2)) [fetched]
  • Pitfall: checking before open(2) "creates a security hole" because the state can change between check and use (access(2) NOTES). [fetched]
  • Python's os.access docs repeat the warning and show the EAFP alternative: try the open and catch PermissionError. (os.access) [fetched]
  • CWE-367 defines TOCTOU as checking "the state of a resource before using that resource, but the resource's state can change between the check and the use". Mitigations listed: drop the check, make check and use atomic, lock before the check, recheck after use, shrink the window. (CWE-367) [fetched]

Amazon S3 Object Lock

  • Signal shape: metadata on the object version, read with HEAD/GET. HeadObject returns x-amz-object-lock-mode (GOVERNANCE | COMPLIANCE), x-amz-object-lock-retain-until-date and x-amz-object-lock-legal-hold, "only returned if the requester has the s3:GetObjectRetention permission" (legal hold: s3:GetObjectLegalHold). Dedicated calls exist as well (GetObjectRetention, GetObjectLegalHold). (HeadObject) [fetched]
  • Write behaviour: a versioned DELETE of a locked version returns 403 Access Denied; a simple DELETE succeeds and adds a delete marker. A PUT to the same key succeeds and creates a new version; the old version stays locked. Governance mode can be bypassed with s3:BypassGovernanceRetention plus the x-amz-bypass-governance-retention:true header. (Object Lock, "How deletes work", "Retention modes") [fetched]
  • Pitfalls: the signal depends on read permission (absent header does not mean unlocked); legal holds can be "freely placed and removed" by anyone with s3:PutObjectLegalHold, so the answer can change between HEAD and DELETE; under variable retention the retain-until date "moves forward as time passes" while an event hold is on. (Object Lock page, "Legal holds", "Retain-until-date behavior") [fetched]

Git LFS lockable files

  • Ownership signal enforced through the file system: files marked lockable "are made read-only in the working copy when not locked by the current user" (lfs.setlockablereadonly, default true). (git-lfs-config, L448-455) [fetched]
  • The authoritative check is server-side at push: Git LFS "verifies that pushes don't alter files locked by others". (git-lfs-lock) [fetched]
  • Pitfall: the read-only bit reflects lock state as of the last checkout or lock command; chmod defeats it locally, and the push check decides. [memory for the staleness; the push check is fetched]

5. Collections (the type-system version of the same question)

  • .NET ICollection<T>.IsReadOnly: a property. Read-only here "does not indicate whether individual elements of the collection can be modified"; an array cast to ICollection<T> reports true while its elements stay writable. Mutators throw NotSupportedException. (ICollection.IsReadOnly) [fetched; the exception is memory]
  • Java Collection: exception only. Mutators are "optional operations" that throw UnsupportedOperationException; the interface has no query for modifiability. (Collection, Java 21) [fetched]

6. Summary table

System Granularity Signal shape Write when read-only Can the answer go stale?
EF Core no-tracking entity instance query (Entry(e).State) silent no-op yes, attach/detach changes it
EF Core keyless entity type model query (IsKeyless) [memory] throws on attach/update no (model is fixed after build)
Hibernate read-only entity in session query (Session.isReadOnly(e)) ignored at flush; delete allowed per session; settable
Hibernate @Immutable entity type annotation ignored, no exception; bulk HQL warns or throws no
ActiveRecord record property (readonly?) raises ReadOnlyRecord set at load, not re-derived [memory]
Realm frozen object version property (isFrozen(), isValid()) throws; findLatest for a writable copy isValid is live
Couchbase Lite document type (Document vs MutableDocument) n/a, no mutators no
LiteDB, SQLite, Core Data store or database option plus store query/property refused by the store rarely
protobuf unknown fields / Any field / payload property list [memory] / is() query preserved / unpack throws no
Avro field / union branch exception only field dropped / error no
Unity missing types managed reference in a host query (bool) plus list (key, type name, data) data preserved; recoverable when type returns answered on demand
POSIX access(2) path query open fails yes, TOCTOU (documented)
S3 Object Lock object version metadata via HEAD/GET versioned delete 403; PUT makes a new version yes, holds and event holds change
Git LFS lockable file file-system read-only bit push refused by server yes, as of last checkout
.NET ICollection collection property NotSupportedException no
Java Collection collection exception only UnsupportedOperationException n/a

Patterns observed, stated as facts across the sources above:

  • Every system that offers an advance signal still has the write path decide: EF Core, Realm, ActiveRecord, S3, Git LFS and POSIX all refuse or ignore at the write regardless of what a check said.
  • Where the read-only state can change after it was observed (POSIX permissions, S3 holds, Git LFS locks, EF Core attach/detach), the documentation either warns of TOCTOU (POSIX, Python, CWE-367) or places the authoritative check at the write (S3, Git LFS push).
  • Silent-ignore write behaviour (Hibernate entities, EF Core no-tracking) is the case where the advance signal is the only signal; Hibernate added an opt-in exception mode for bulk updates.
  • The closest analog to records under an unregistered schema id (Unity missing types) exposes both an on-demand bool query and an on-demand list carrying the reference id and the recorded type name, and recovers the data when the type returns.
  • Store-level read-only (LiteDB, SQLite, Core Data, and CultCache's own CacheBackingStore.IsReadOnly) is a property or query on the store, not on the record.