Concepts

Event windows

Understand bounded last-N event snapshots and multi-fact groups.

Maelys Datalog normally evaluates one complete input snapshot per solve. A window adapter maintains a bounded series of accepted events for a native C application, constructs the current EDB snapshot, and solves it again. It is not a streaming rule language or an incremental solver: the Datalog rules are unchanged.

What problem does a window solve?

The quickstart asks whether someone may read a document for one request. A window lets the application ask a different question: has someone tried to read that document three times without permission, among the last five requests?

An ordinary solve only knows the input facts supplied to it. It does not remember an earlier request. The window keeps a small history, removes its oldest event when necessary, and supplies the remaining history to a fresh solve.

ARCHITECTUREFrom a request to a published snapshot
    • New requestthe application observes it
    • Keep the last Npropose expiring the oldest
    • Evaluate the historyordinary Datalog rules
    • Publish on successnew snapshot and answer together

Event, fact, and snapshot

These are three different things:

  • An event is one item accepted into the history. It can represent a document request, a login attempt, or a group of related observations.
  • A fact describes something the rules can inspect, such as request(0, "mallory", "roadmap.pdf").
  • The current snapshot is the complete collection of input facts retained right now. The solver derives its answer from this snapshot, not from an invisible past.

The application decides what an event represents. The adapter decides which accepted events remain. The rules decide what follows from their facts.

Two event models

IntroducedNative adapterOne accepted event meansIdentity seen by rules
0.8.0Last-N event windowOne fact, with an occurrence number inserted as its first termThe generated integer distinguishes repeated observations.
0.9.0Multi-fact group windowZero or more complete facts, accepted togetherThe group number is metadata; rules see only the supplied facts.

Both retain the last N successfully accepted events in arrival order. When the window is full, accepting a new event expires the oldest one. A rejected event changes neither the committed input nor the result or next identifier.

Follow a document request through the window

For this additional example, keep the access rights fixed in policy facts: Alice and Mallory own the document, Bob has delegated access, and Mallory is blocked. Unlike the quickstart's runtime blocked input, this example puts that fixed context in the policy so it does not expire with requests.

DATALOG
owns("alice", "roadmap.pdf").
owns("mallory", "roadmap.pdf").
delegated("bob", "roadmap.pdf").
blocked("mallory").

can_read(User, Doc) :-
    owns(User, Doc) or delegated(User, Doc),
    not(blocked(User)).

requested(User, Doc) :- request(_, User, Doc).
denied(Id, User, Doc) :-
    request(Id, User, Doc),
    not(can_read(User, Doc)).
denials(User, Doc, N) :-
    requested(User, Doc),
    count(Id, denied(Id, User, Doc), N).
alert(User, Doc) :- denials(User, Doc, N), N >= 3.

Read the rules in this order:

  1. can_read derives who has permission, using the same ownership/delegation rule as the quickstart.
  2. denied records which retained requests have no corresponding can_read fact.
  3. denials counts the different request IDs for each user/document pair. requested supplies the pairs to examine.
  4. alert is present when that count reaches three. This is an alert about the retained history, not a permanent account lock or an instruction that executes itself.

The domain must declare owns/2, delegated/2, and blocked/1 as policy-fact predicates; request/3 as EDB; and the other predicates as IDB. Add QUERY permission to denials/3 and alert/2 to inspect them. Declare the four quoted source strings in atoms. The C walkthrough shows these declarations.

Watch the oldest request expire

Choose N = 5 and start the generated request IDs at zero. All requests below concern roadmap.pdf; the ID identifies an occurrence, not a position that will be reused.

Accepted requestUserIDs retained after acceptanceMallory's denialsalert("mallory", "roadmap.pdf")
0Mallory01Absent
1Mallory0, 12Absent
2Alice0, 1, 22Absent
3Mallory0, 1, 2, 33Present
4Bob0, 1, 2, 3, 43Present
5Bob1, 2, 3, 4, 52Absent

The last row is the important one. Request 5 enters, request 0 leaves, and the rules run again. Mallory now has only two denials in this history, so the alert disappears. Nothing revoked Mallory's blocked status: the history changed, not the access policy.

N counts all accepted requests in this window, not five requests per user and not five denials. Alice and Bob's requests also occupy slots and can expire Mallory's older requests. Separate per-user histories require separate windows or application-managed partitioning.

Why repeated requests need IDs

The facts request(0, "mallory", "roadmap.pdf") and request(1, "mallory", "roadmap.pdf") describe two different occurrences. Counting their IDs gives two.

Without an occurrence term, two identical request("mallory", "roadmap.pdf") facts are the same Datalog fact and appear once. They cannot represent two attempts for a distinct count. The single-fact adapter adds the occurrence ID for you; the group adapter does not.

When one event needs several facts

One request may carry several observations: who requested a document, from which country, and from which device. The group adapter accepts them together and expires that group's contributions together.

For example, the application can give one request its own ID, 42, and submit:

DATALOG
request(42, "mallory", "roadmap.pdf").
country(42, "FR").
device(42, "laptop").

Another request uses another ID. A rule can join request(Id, User, Doc) with country(Id, Country) without accidentally attaching one request's country to another. The explicit Id connects the facts; putting them in the same group does not create a hidden relationship that Datalog can see.

The adapter also assigns a group number for inspection by the application, but that number is not inserted into these facts. A group can contain several request IDs, or no request at all.

A group is not an isolated scope

The rules read the union of all retained groups, not each group separately. Equal facts appear once in that union. If two retained groups both supply the same fact, expiring one group does not remove it: the other still supplies it.

This is useful for a question such as “how many different countries appear?”, but it does not count connections. Two observations of from_country("alice", "FR") still describe one distinct country. To count connections instead, keep a separate occurrence ID in each connection fact.

Why the distinction matters

Suppose the application receives the same reading twice. With the 0.8.0 adapter, the engine sees reading(0, 5) and reading(1, 5): the generated occurrence numbers preserve two observations. Counting occurrence IDs gives two; counting distinct readings gives one. A sum over the reading values sees both facts and gives ten.

With the 0.9.0 adapter, a group contributes complete facts. Group A supplies reading(1, 5) and reading(2, 5); group B supplies reading(1, 5) again. The committed input is the set union of the retained groups, so the shared fact appears once. When A expires, reading(1, 5) remains because B still supplies it. The group identifiers are not inserted into Datalog terms. If two otherwise identical observations must remain distinct to the rules, put an occurrence value in the facts you supply.

Retained groupsRaw contributionsDistinct runtime factsSum of reading values
A and B3210
B and an empty C115
Empty C and empty D000

An empty group is still an event: it advances retention and may expire an older group. Policy facts are separate from this runtime window and do not expire with its input facts.

One transaction, one result

For either adapter, a push builds a candidate snapshot and evaluates it before publishing anything. Success publishes the retained input, result and cursor together. On any failure, the previous committed result and its borrowed views remain valid. External side effects of application callbacks are not rolled back.

One copy of the current history remains available while a separate candidate is evaluated. This separation lets a failed attempt leave the published answer intact. The C lifecycle explains the two sessions and their ownership.

What the window does not decide for you

Recent events are not an elapsed duration

A window has no clock. Five pushes in a millisecond and five pushes in a day both fill a five-event window. Nothing expires merely because time passes without another accepted push.

An application can group observations into time buckets, such as one group per second, and push an empty group for an empty bucket. Sixty retained groups then represent sixty accepted buckets. This only tracks the intended schedule if the application supplies every bucket, including empty ones, and handles rejected pushes. It is a bucketed approximation, not an exact continuous “last sixty seconds” query. Out-of-order timestamps do not reorder the window.

For a precise time interval, the application must select facts using timestamps and a trusted current time, then supply the intended snapshot. Neither adapter performs that selection automatically.

An observation is not necessarily current state

If an old group contains owns("alice", "roadmap.pdf"), that fact remains available until its last supplying group expires. A later observation that omits ownership does not delete the old one. A window is a history of retained observations, not a database that overwrites a user's current permissions.

In our example, fixed rights are policy facts. They stay fixed for the lifetime of the prepared policy. If rights change, the application must prepare the appropriate new policy/state; another request push does not update them. Historical requests are evaluated using the context present in the snapshot, not automatically with the permissions that existed when each request happened.

An alert is not a quota reservation

Our denials counts observed requests. It does not record which operations an application actually executed. Querying a derived permission does not consume a slot, debit a quota, or reserve a resource.

For a quota on successful operations, the application needs an authoritative record of committed operations and a serialized check/commit procedure. Do not define “granted” from a count of those same grants: that feeds the decision back into the relation being counted, an aggregate dependency cycle that the language rejects. Counting independently supplied records of earlier successful operations is a different, valid model. See aggregate stratification.

Windows, Biscuit scopes, and Binder contexts

These mechanisms all select relevant information, but along different dimensions:

MechanismQuestion it answersWhat establishes the boundary?
Maelys event windowWhich recently accepted observations are still in this snapshot?Arrival order and a fixed number of events/groups.
Biscuit trust scopeWhich origins may this rule or check use?Trusted token blocks and the authorizer.
Binder contextWhich issuer's statement may become a local assertion?Signed imports and explicit trust/delegation rules.

What a Biscuit scope actually does

Imagine an issuer grants read access to one file. A token holder appends a block claiming read access to a second file. The server must not accept that new claim as an issuer-granted right.

Biscuit scopes restrict which origins a rule or check trusts. Server-side evaluation normally trusts the authority block and the authorizer, not arbitrary appended blocks. Trust can be extended explicitly, including to blocks verified with a specified public key. A derived fact carries its contributing origins, so an intermediate rule cannot hide an untrusted source. See the Biscuit scope specification.

A window answers no such trust question. A recent forged fact is still forged. Maelys's EDB/IDB/policy-fact roles describe how facts enter evaluation; they do not authenticate an issuer. The application must verify and admit its inputs. A group number or an issuer string is not a signature.

What Binder contributes to the comparison

Binder distinguishes an imported, signed statement from a local assertion through says. Local rules explicitly decide whether to trust that issuer. This concerns authority and delegation, not keeping the last N events. Maelys does not implement Binder's certificate import or says syntax. See Related work.

Recipe ideas that can be reused

The following are modelling ideas to adapt, not compatibility with Biscuit syntax or its token protocol:

  • Resource and operation facts. Supply the actual resource and requested operation from the application, then join them with rights. Common patterns also illustrates that expiration needs current time supplied by the verifier; a history window is not a substitute for that clock.
  • Roles scoped to a resource or tenant. Keep the resource/tenant key in both role membership and permission relations so a role in one project does not grant rights in another. In Maelys, represent memberships and permissions as ordinary relational facts rather than Biscuit's set-valued terms. See the RBAC recipe.
  • Request-specific restrictions. Add conditions for the exact resource, operation, or deadline being checked. With history, carry the request ID through related observations so two requests cannot exchange context. The per-request attenuation recipe additionally relies on signed token attenuation, which Maelys does not provide.

Freshness and trust can be combined by the application: verify the source, admit the observation, retain it, then evaluate the policy. Neither one replaces the other.

Capacity is not just N

N bounds retained events or groups, not the whole engine. Text, fact, per-predicate, derived-fact, depth and explanation limits still apply. The multi-fact adapter separately bounds retained raw contributions, distinct union facts and per-bank interned text. Duplicates consume contribution slots even when the union is small. Neither adapter grows its storage or silently shrinks the window on saturation.

The windows are currently exposed by the native C SDK only. Python and JavaScript/WASM do not expose these adapters. For the public C functions and ownership sequence, see Event windows in the C API.

What you have learned

KEEP THIS MODEL

Retain observations, not permissions
  • The window retains input history

    Each accepted push leads to a full solve of the retained snapshot. The rules themselves have no hidden memory.

  • Choose what one event means

    The single-fact adapter inserts an occurrence ID. The group adapter retains related contributions together but does not inject an ID into them.

  • Retention is not time or trust

    N counts accepted events/groups. The application supplies clocks, authenticates inputs and manages current permissions.

  • Failure preserves the earlier snapshot

    A rejected event is not included in its answer. Do not authorize that new event from an old result.