Policy Engine
The Policy Engine governs the data lifecycle and access control of every strand. Retention policies cryptographically tombstone records when they age out — provable deletion, not silent erasure. Field-level ACL policies strip sensitive fields from API responses without touching the stored data. Row-level policies restrict which records a JWT-authenticated caller may see or write at all — multi-tenant isolation enforced by the database layer, not application code.
- ▸Define max_age_secs per policy
- ▸Background agent tombstones expired records every 5 min
- ▸TOMBSTONE nucleotide = cryptographic proof of deletion
- ▸Most restrictive rule wins when multiple apply
- ▸Define denied_fields per policy
- ▸Applied inline at every read response boundary
- ▸Stored strand data is never modified
- ▸All matching policies union their denied_fields
- ▸Filter template with $jwt.sub / $jwt.email placeholders
- ▸select · insert · update · all — which direction(s) apply
- ▸Enforced on every read + write path, and both SSE streams
- ▸JWT-authenticated callers only — root/service keys bypass
Retention Policies
A retention policy defines how long records survive before the enforcement agent writes a TOMBSTONE nucleotide against them. The enforcement cycle runs every 5 minutes. Multiple retention policies are applied with the most restrictive rule winning — if two policies match and one says 30 days while the other says 90, the 30-day rule applies.
Create a retention policy
{
"name": "GDPR 90-day",
"max_age_secs": 7776000,
"classification_tag": null,
"priority": 10
}
// → { "id": "a1b2c3d4…", "name": "GDPR 90-day", "max_age_secs": 7776000,
// "priority": 10, "created_at_ms": 1747003200000, ... }List / get / delete
{
"retention": [
{ "id": "a1b2…", "name": "GDPR 90-day", "max_age_secs": 7776000, "priority": 10, ... }
],
"acl": [...]
}// GET → 200 RetentionPolicy | 404 if not found // DELETE → 204 No Content
Field-Level ACL Policies
An ACL policy names the payload fields that must be stripped before any API response leaves the agent. The stripping happens at every read boundary — GET /v1/records/:hash, GET /v1/strand/records, POST /v1/query, and POST /v1/query/semantic. The stored strand data is never modified; only the payload_b64 in the response is re-encoded without the denied keys. The content hash in the response still reflects the original unmasked data.
Create an ACL policy
{
"name": "Redact PII",
"denied_fields": ["ssn", "dob", "credit_card"],
"principal": "*",
"priority": 5
}
// → { "id": "f7e8d9c0…", "name": "Redact PII", "denied_fields": ["ssn","dob","credit_card"],
// "principal": "*", "priority": 5, "created_at_ms": 1747003200000 }
// → 400 Bad Request if denied_fields is emptyList / get / delete
// GET → 200 AclPolicy | 404 if not found // DELETE → 204 No Content
Row-Level Policies
A row policy restricts which records a JWT-authenticated caller may read or write on a given agent — the owner_id == current_user guarantee a multi-tenant app needs, enforced by the database instead of application code. This requires the Auth add-onto be enabled; root keys and scoped API keys are never JWT-authenticated, so they are always unaffected by row policies — the same bypass rule ACL's principal="*" wildcard already follows.
filter_template is a normal SaQL filter that may reference five placeholders, resolved at request time: $jwt.sub (user ID), $jwt.email, $jwt.app_metadata.<key>, $jwt.user_metadata.<key>, and $lookup.<name>— a value read fresh from another agent's current data, declared via the policy's lookups array (see below). A missing $jwt.* key, or a lookup with no matching record, resolves to null rather than erroring.Live policy lookups
$jwt.*placeholders are snapshotted at token issuance — a policy built only from JWT claims can lag reality until the token refreshes (e.g. "does this user currently own this business profile" is wrong for anyone who took ownership after their token was minted). A PolicyLookupcloses that gap: each one runs a fresh, single-record query against a (usually different) agent's live data on every enforcement, and binds the result to $lookup.<name> for filter_template to reference.
{
"name": "owned_biz",
"agent_id": "businesses",
"match_field": "owner_id",
"match_value": "$jwt.sub",
"return_field": "business_id"
}
// filter_template can then reference $lookup.owned_bizAdditive, not a replacement for $jwt.app_metadata.*— reach for a lookup only where staleness up to the token's expiry would be a real correctness problem; it costs one extra query per enforcement per lookup. A $lookup.*string placed in a lookup's own match_valueis never resolved (chaining one lookup into another's match condition is structurally blocked), and a lookup naming the same agent as the policy it belongs to resolves to null without ever querying.
Create a row policy
{
"name": "owner-isolation",
"agent_id": "orders",
"cmd": "all",
"filter_template": { "field": "owner_id", "op": "eq", "value": "$jwt.sub" },
"priority": 1,
"lookups": []
}
// → 201 Created — full RowPolicy object, including generated id + created_at_ms
// → 404 if "agent_id" isn't a registered agent on this nodeList / get / delete
// GET /v1/policies/row → [RowPolicy, ...] (a plain array, across every registered agent) // GET /v1/policies/row/:id → RowPolicy | 404 if not found // DELETE /v1/policies/row/:id → 204 No Content (idempotent — 204 even if the id doesn't exist)
GET /v1/policies (the combined listing endpoint) includes a "row" array alongside "retention" and "acl".
Enforcement surface
Row policies are enforced on every read and write path that touches record payloads —select/all policies filter query results (including every indexed fast path), and a single-record fetch by hash returns 404 rather than 403on a mismatch so a caller who can't see a record can't even confirm it exists. insert/allpolicies check a write payload before it's committed. Claim-style atomic select-then-write operations apply both halves — a caller can't claim a record they couldn't otherwise see, and the resulting transition is still Insert-checked. This holds for POST /v1/agents/:id/claim and for POST /v1/transact's claim op equally; a write_if op using patch (partial update) instead of a full-replace data payload gets the same Insert-side check against its merged result.
Both realtime SSE streams and the Publications stream apply the same Select filter server-side before an event reaches a connection — resolved once from the JWT presented at connect time and fixed for the connection's lifetime.
GET /v1/strand/records, GET /v1/control/*, GET /v1/hipaa/audit-report — would be misleading if row-filtered, so they instead require the root key or a scoped key holding an admin:<resource> scope. See the Scope Syntax section.Conflict Resolution Hierarchy
Applies to Retention and ACL policies. Row policies AND-combine independently of this hierarchy — every matching select/insert policy for an agent must pass, there is no priority-based override.
When multiple policies apply to the same record or response, the conflict resolution rules determine the effective behavior. Call GET /v1/policies/hierarchy to see the computed effective order at any time.
{
"resolution_rules": [
"ACL policies are additive: denied_fields from all matching rules are unioned",
"Retention policies are restrictive: most limiting max_age_secs wins",
"Higher priority number = checked first; ties broken by creation order (oldest first)"
],
"effective_order": [
{ "kind": "retention", "id": "a1b2…", "name": "GDPR 90-day", "priority": 10, "description": "Tombstone after 7776000s" },
{ "kind": "acl", "id": "f7e8…", "name": "Redact PII", "priority": 5, "description": "Redact: ssn, dob, credit_card" }
]
}Python
pip install sapixdb-policy
import asyncio
from sapixdb_policy import PolicyClient
async def main():
async with PolicyClient("http://localhost:7475") as pol:
# Retention: tombstone records older than 90 days (GDPR right-to-erasure)
gdpr = await pol.gdpr_retention(90, priority=10)
print(f"Created retention policy: {gdpr.id} ({gdpr.max_age_secs}s)")
# Shorter window for high-sensitivity records
ccpa = await pol.create_retention(
"CCPA 30-day",
max_age_secs=30 * 86_400,
priority=20, # checked first — stricter wins
)
# Field-level ACL: strip PII from every API response
pii = await pol.redact_fields(
["ssn", "dob", "credit_card", "bank_account"],
name="PII Redaction",
priority=5,
)
print(f"ACL policy: {pii.id} fields={pii.denied_fields}")
# Inspect the effective policy order
h = await pol.hierarchy()
print("\nResolution rules:")
for rule in h.resolution_rules:
print(f" • {rule}")
print("\nEffective order (highest priority first):")
for entry in h.effective_order:
print(f" [{entry.kind:<10}] {entry.name:<20} priority={entry.priority}")
print(f" {entry.description}")
# Remove the GDPR policy
await pol.delete_retention(gdpr.id)
print(f"\nDeleted policy {gdpr.id}")
asyncio.run(main())PolicyClient API
sapixdb-agent, SapixClient exposes raw-dict wrappers — policy_list(), policy_hierarchy(), policy_create_retention(), policy_get_retention(), policy_delete_retention(), policy_create_acl(), policy_get_acl(), policy_delete_acl(). Use sapixdb-policy for typed Pydantic models and the gdpr_retention() / redact_fields() helpers.