98 Read-only Models over Weakly Constrained Data

A general implementation strategy is to keep the updatable data in partitions with deliberately weak constraints. Those partitions accept local writes quickly, support high ingestion rates, and allow concurrency patterns such as independent partition ownership, operational transformation, replication, and asynchronous merge.

Read-only models sit above that mutable state. They may be maintained asynchronously, or they may be computed by read-only queries over MVCC or snapshot-isolated views that span many partitions, provided each query sees a stable read view. Querying many partitions is not the same as updating many partitions: writes remain partition-local even when reads combine replicated, projected, or snapshot information from several partitions.

The essential mathematical point is that a read-only model may be a non-injective function of the mutable data. Different mutable states may map to the same read-only state. That non-injectivity is what allows the read-only model to impose idealised, complex constraints without requiring the mutable partitions to reject data at ingestion time.

This fits naturally with components. The updatable partition is one component. A separate component defines the pristine read-only model. The two components may use the same ordinary type names because components are namespaces.

For example, the updatable component may record customers and orders with weak constraints:

component SalesMutable;

entity Customer
{
    Text email;
    Text name?;
}

entity Order
{
    Text customerEmail;
    Text orderNumber;
}

entity Sales
{
    Customer customer*;
    Order order*;
}

The read-only component then imports the mutable component and exposes its own Sales, Customer, and Order types. These are not the same types: they are declarations in the read-only component. The root object has a base attribute that points at a snapshot of the mutable root:

component SalesPristine;

using SalesMutable as mutable;

entity Sales
{
    snapshot mutable.Sales source;

    → Customer customer* = source.customer.[
        let c=this in source.customer.[email = c.email].Σcount = 1
    ].(
        Customer { email = email; name = name }
    );
}

The snapshot qualifier is proposed notation rather than ordinary base-attribute syntax. It means that source is supplied by the runtime as an MVCC or snapshot-isolated view of another partition or component root. It is a base attribute of the read-only root object, but it is read-only in this context: Predica expressions can navigate through it, but updates cannot be performed through it.

The derived customer attribute is a non-injective function from the snapshot to the pristine model. If two mutable customers have the same email, neither need be projected into the read-only model. The conflicting facts remain in the mutable component, but readers of SalesPristine.Sales.customer see a model satisfying the intended key constraint.

The same pattern can impose a functional dependency. Suppose the pristine model wants email to determine name. The mutable component may still record conflicting names, while the read-only component keeps only customers whose names are unambiguous:

entity Sales
{
    snapshot mutable.Sales source;

    → Customer customer* = source.customer.[
        let c=this in source.customer.[email = c.email].name.Σcount <= 1
    ].(
        Customer { email = email; name = name }
    );
}

The read-only schema can also drill down into dirty data and rebuild structure using its own types. For example, it can expose pristine orders only when the referenced customer survived the customer projection:

entity Customer
{
    Text email;
    Text name?;
}

entity Order
{
    Customer customer;
    Text orderNumber;
}

entity Sales
{
    snapshot mutable.Sales source;

    → Customer customer* = source.customer.[
        let c=this in source.customer.[email = c.email].Σcount = 1
    ].(
        Customer { email = email; name = name }
    );

    → Order order* = source.order.[customerEmail ∈ customer.email.Σ].(
        Order {
            customer = customer.[email = customerEmail];
            orderNumber = orderNumber;
        }
    );
}

This derived order attribute imposes an inclusion dependency by throwing out orders whose customerEmail is not present in the pristine customer projection. It also changes shape: mutable orders refer to customers by email text, while pristine orders refer to a pristine Customer entity. The read-only component is therefore not merely filtering rows. It is interpreting weakly constrained data and rebuilding a more useful structure.

The cross-partition and asynchronous boundary is therefore a runtime contract around snapshot attributes. A snapshot attribute may be backed by an MVCC snapshot, by a replicated projection, or by an asynchronous feed that materialises a stable read view. The Predica definitions describe the pure read-only function from that stable input to the pristine model. The implementation decides whether the function is recomputed on demand, incrementally maintained, or served from a stale but consistent projection.

This chapter therefore points to a likely language extension: Predica needs a way to declare that an attribute of a read-only root is a snapshot of another component root, possibly from another partition. Such an attribute has a base value supplied by the runtime, but it is read-only to Predica update statements. Derived attributes in the read-only component can then define non-injective functions over that snapshot, throwing away duplicates, dangling references, ambiguous facts, or work in progress.

This is why the pattern belongs with updates even though the model itself is read-only. Weak constraints on updatable data make updates local, fast, and mergeable. Read-only non-injective functions recover the stronger, idealised models needed by readers, reports, validation workflows, security views, and downstream services.