SapixDBSapixDB/Docs
Home
Phase 3 · Policy Engine

Policy Engine

⚡ Partialclassification_tag is stored but not yet enforced; per-caller ACL requires Codios integration.

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.

Retention
  • ▸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
Field-Level ACL
  • ▸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
Row-Level Isolation
  • ▸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
✓ Strand data is never silently deletedA tombstone is itself a signed, hash-linked nucleotide appended to the strand. It proves deletion happened at a specific time — which is itself immutable evidence for GDPR right-to-erasure audits, HIPAA breach investigations, and SOX change trails.

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.

FieldTypeDescription
namestringHuman-readable label for the policy.
max_age_secsu64Age limit in seconds. 0 = no limit (keep forever).
classification_tagstring | nullOptional tag to scope the rule (matches _tag in payload). Stored but not yet enforced — see limitations.
priorityu32Higher = checked first. Tie-breaking by insertion order (oldest first).

Create a retention policy

POST /v1/policies/retention
{
  "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

GET /v1/policies
{
  "retention": [
    { "id": "a1b2…", "name": "GDPR 90-day", "max_age_secs": 7776000, "priority": 10, ... }
  ],
  "acl": [...]
}
GET /v1/policies/retention/:id | DELETE /v1/policies/retention/:id
// 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.

⚠ Non-object payloads are returned unchangedField masking only applies to MessagePack maps (objects with named keys). Scalar payloads, arrays, and binary blobs that cannot be decoded as MessagePack are passed through without modification.
FieldTypeDescription
namestringHuman-readable label.
denied_fieldsstring[]Top-level payload field names to redact. Must be non-empty (400 otherwise).
principalstring | null"*" = all callers (default). Per-caller enforcement requires Codios integration (Phase 4.1).
priorityu32Higher = checked first. ACL policies are additive — all matching rules apply.

Create an ACL policy

POST /v1/policies/acl
{
  "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 empty

List / get / delete

GET /v1/policies/acl/:id | DELETE /v1/policies/acl/:id
// 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.

ℹ Placeholders resolved from the caller's JWT — and, optionally, from live dataA row policy's 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.

one entry in a RowPolicy's lookups array
{
  "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_biz

Additive, 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.

FieldTypeDescription
namestringHuman-readable label for the policy.
agent_idstringThe agent this policy applies to.
cmd"select" | "insert" | "update" | "all"Which direction(s) this policy governs. update is reserved — SapixDB has no dedicated update write path today (every write is an append); use all.
filter_templateFilterExprA normal SaQL filter, with optional $jwt.* / $lookup.* placeholders.
priorityu32All matching policies for a command are AND-combined regardless of priority.
lookupsPolicyLookup[]Optional, default []. Live lookups this policy can reference as $lookup.<name> — see below.

Create a row policy

POST /v1/policies/row
{
  "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 node

List / get / delete

GET /v1/policies/row | GET /v1/policies/row/:id | DELETE /v1/policies/row/:id
// 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.

⚠ A handful of audit/debug views are admin-gated, not row-filteredRaw chronological dumps meant to show everything — 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.

1
ACL policies are additive
denied_fields from all matching ACL policies are unioned. If Policy A denies ["ssn"] and Policy B denies ["dob"], both fields are stripped from every response.
2
Retention policies are restrictive
The most limiting max_age_secs wins. If Policy A sets 30 days and Policy B sets 90 days, records are tombstoned at 30 days.
3
Higher priority = checked first
Ties are broken by insertion order — oldest policy first. Set priority explicitly to control ordering when precision matters.
GET /v1/policies/hierarchy
{
  "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

installation
pip install sapixdb-policy
GDPR + PII redaction + hierarchy inspection
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

MethodDescription
list_policies()Return all retention and ACL policies.
hierarchy()Return the computed effective priority order and resolution rules.
create_retention(name, max_age_secs, *, classification_tag, priority)Create a retention policy. max_age_secs=0 means keep forever.
get_retention(policy_id)Get a retention policy by id. Raises 404 if not found.
delete_retention(policy_id)Delete a retention policy (204 No Content).
create_acl(name, denied_fields, *, principal, priority)Create a field-level ACL policy. denied_fields must be non-empty.
get_acl(policy_id)Get an ACL policy by id. Raises 404 if not found.
delete_acl(policy_id)Delete an ACL policy (204 No Content).
gdpr_retention(max_age_days, name, priority)Convenience: create a retention policy expressed in days.
redact_fields(fields, name, priority)Convenience: create a wildcard ACL policy for the given field names.
ℹ SapixClient convenience methodsIf you are already using 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.

Known Limitations

⚠ classification_tag not yet enforced
The tag field is stored and returned but not yet used for matching. Currently all retention policies apply to all records. Tag-based scoping is planned for 3.5.1.
⚠ principal defaults to wildcard
ACL principal="*" applies to all callers. Per-caller identity enforcement requires Codios integration (Phase 4.1).
⚠ Full strand scan on retention cycle
The 5-minute enforcement task scans the entire strand. For very large strands (>10M records), a time-indexed scan would be more efficient — planned after Phase 4.
Healthcare or financial workload?

The HIPAA package adds PHI field classification and access logging. The SOX package adds financial agent designation and dual-admin sign-off enforcement.