Open source · Rust · PostgreSQL 18

Build on data
you can't see.

Tinker is an ontology-driven builder for internal tools. Define an object once and it becomes a real Postgres table behind row-level security, a governed API, and an MCP front door for agents. Personal data is sealed by type. Every read gets a mask and automations get a key. A person sees the value only when a second person approves.

● Rust workspace PostgreSQL 18 + RLS AES-256-GCM vault HMAC blind index MCP · stdio + HTTP/SSE Passkeys · OIDC + PKCE MIT
customerrec_01J9…7f2c
name
Ada Lovelace
emailpii · sealed
ada@analytical.engine••••••pk_3f9a0c71e2…b84d
phonepii · sealed
+44 20 7946 0018••••••pk_c41e96d07a…19f0
plan
Enterprise
status
published · v3
org scoped · RLScore stores a vault refemail__bidx
29
Rust crates
91k
lines of Rust
662
tests passing · 0 failing
59
SQL migrations
12
MCP tools
0
plaintext in sealed columns
01 — SEALED FIELDS

One field.
Three faces.

Email and phone fields are personal data by type, so they are always sealed. Text fields opt in. The core database never holds the value. It holds a vault reference and a keyed blind index, and each audience gets exactly the face it needs.

default

Masked in the compiler, not the UI.

The typed query compiler writes the mask into the SQL itself, so every consumer that builds on it inherits the mask and none of them can forget it.

  • Exact match still works. eq, ne and in go through the HMAC-SHA256 blind index.
  • Anything that would leak is refused. That covers contains, ranges, sorting and row policies on sealed fields.
  • Copied sealed forms are rejected. You can't paste one record's vault reference into another.
// MCP · query
{ "object": "customer",
  "intent": {
    "select": ["name", "email",
               "phone", "plan"],
    "filters": [{
      "field": "email", "op": "eq",
      "value": "Ada@Analytical.Engine " }] } }

// → rows, matched via email__bidx
[{ "name":  "Ada Lovelace",
   "email": "••••••",
   "phone": "••••••",
   "plan":  "Enterprise" }]

// "order": [{ "field": "email" }]
error: field 'email' is sensitive and
       cannot be sorted on
automation key

Stable for a value. Useless to anyone else.

pk_ followed by 40 hex characters of HMAC over the blind index, in a separate domain. The same email always gives the same key, so you can join, dedupe and route on it. The key won't query the index and can't be reversed.

  • Scoped per field, like the index. Keys are never comparable across objects, which keeps cross-object linkage closed.
  • Shows up wherever a value would leak: run logs, webhook payloads, exports.
// MCP · query, selecting keys
{ "object": "customer",
  "intent": { "select": ["name", "email:key"] } }

// → rows
[{ "name": "Ada Lovelace",
   "email:key": "pk_3f9a0c71e2…b84d" },
 { "name": "A. Lovelace",
   "email:key": "pk_3f9a0c71e2…b84d" }]
// same person, found without seeing the address
four eyes

Plaintext is an event, not a read.

The reveal tool returns one field of one record. It needs an owner or admin with an explicit scope, a stated purpose, and an approval decided by someone else. That approval works only once.

  • The vault is a separate database with its own roles. Core SQL can't join to it, and a test proves that.
  • Each record has its own key (AES-256-GCM, wrapped by a rotating KEK), so erasing the record destroys the key.
// MCP · reveal
{ "object": "customer",
  "record_id": "01J9…7f2c",
  "field": "email",
  "approval_request_id": "01J9…a40e" }

// → value (audited as pii.reveal)
"ada@analytical.engine"

// same approval, second time
error: reveal needs an approved pii.reveal
  request for this field, filed by you and
  approved by someone else
02 — THE DATA PATH

Every path goes
through the governor.

The builder, the HTTP API, the CLI, live grids, search, dashboards and agents all go through one compiler. Tenancy is enforced in Postgres, not in application code you have to trust.

01 · CREDENTIAL

Who is asking

Machine keys (tk_…), passkeys (WebAuthn ES256/EdDSA, UV for MFA) or OIDC code flow with PKCE, state and nonce. Each resolves to one actor, one org and one role.

02 · TENANT TX

Which org, by the database

SET LOCAL app.organization_id inside a transaction on a role that can't bypass RLS. The tenant comes from the credential, never from the caller.

03 · COMPILER

What they may see

Role projection, row policies, masking and the blind index are compiled into one SQL statement. Unauthorized rows come back not_found, so there's no existence oracle.

04 · GOVERNED WRITE

How it changes

Validation, lifecycles (draft → review → publish), approvals bound to the action, and an automation event written in the same transaction.

CORE · PG18

Real tables

One table per object. Sealed columns hold a vault ref plus __bidx.

no join
VAULT · PG18

Ciphertext

Per-subject DEKs wrapped by the KEK, with every access audited.

Ontology → real tables

Objects, fields, relations and lifecycles are metadata that materializes into real Postgres DDL, not an entity-attribute-value store. Schema changes ship as versioned evolutions with canary and promote.

Fail closed, loudly

A missing extension, an unset vault, a dead IdP or a half-configured key gives a typed error at startup or call time. Nothing silently falls back to plaintext.

Durable by default

DBOS-style workflows, queues and timers on Postgres itself. Outboxes for email and automations mean no event is lost and none is invented.

03 — AUTOMATIONS

Automate on email addresses
no one can read.

Conditions compare keys and actions resolve values only at the moment they run. Typed literals are turned into keys when the automation is saved. The saved definition never contains the plaintext you typed into it.

What the author typesplaintext · once
// MCP · automation
{ "action": "save",
  "object": "customer",
  "name": "VIP escalation",
  "trigger": { "on": "record_updated",
               "fields": ["email"] },
  "conditions": [{ "field": "email", "op": "eq",
    "value": "ceo@northwind.example" }],
  "actions": [
    { "type": "send_email", "to_field": "email",
      "subject": "Your account team",
      "body": "Hi {{ name }}, …" },
    { "type": "webhook",
      "url": "https://hooks.example/vip",
      "fields": ["name", "email"] }
  ] }
What gets storedkeys only
-- automations.conditions (jsonb)
[{ "field": "email",
   "op": "eq",
   "keyed": true,
   "value": "ceo@northwind.example""pk_8d07e5b2a9…41c2"
}]

-- run_as_role: admin (re-checked every run)
-- send_email.to_field → a vault ref in the outbox,
--   resolved in-process at send, audited,
--   never written back
-- webhook body → ids, "fields": {"name": …},
--   "keys": {"email": "pk_3f9a…b84d"}
--   Idempotency-Key: automation:event:action
automation_runsoutcomes and keys, never values
eventrecordemail:keyoutcomedetail
updated01J9…7f2cpk_3f9a…b84dsucceededemail queued · webhook 200
updated01J9…c0d1pk_77e0…2a19skippedcondition false
updated01J9…7f2cpk_3f9a…b84dloop_blockeddepth 3
tx

Outbox in the same transaction

An event exists exactly when the write commits. Workers claim with SKIP LOCKED, and each (automation, event) pair runs once.

3

Loop depth

Writes made by an action carry the chain depth forward. At depth 3 the run records loop_blocked and stops.

20/h

Guessing guard

An equality literal is a yes-or-no question about a value, so each actor can save at most 20 sealed literals per hour. Saves are audited with counts only.

=

Never sees more than its author

Runs as the author's role, under that role's projection and row policy. If the author loses the role, the next run fails closed.

04 — REVEAL

Two people.
One field. One time.

This uses the same four-eyes approvals that guard publishing, bound to the exact (object, record, field). The approval is consumed atomically with the reveal.

1
SB
support-botagent · tk_ key · admin

Asks, with a reason

It files a pii.reveal request for one field. The purpose is stored and audited.

request_reveal { field: "email", purpose: "support ticket 42" }
2
MR
Maya R.human · passkey · admin

Someone else decides

A different owner or admin lists the pending requests and approves or denies them. The requester can never decide their own request.

approvals { action: "list" } approvals { action: "approve", id: "01J9…a40e" }
3
SB
support-botsame requester

Reveals, once

It must come from the same requester, for the same field, before the approval expires. The vault decrypts in-process and logs the access.

reveal { field: "email", approval_request_id: "01J9…a40e" } → "ada@analytical.engine"
✕

Self-approval. The requester is recorded on the request and refused at decision time, whether they approve or deny.

✕

Replay. A second reveal with the same approval fails. So does a reveal of a different field or record.

✕

Blanket scopes. mcp:tools doesn't cover reveal. The key needs mcp:tool:reveal by name.

05 — ERASURE

Forget someone
by destroying a key.

Every record has its own data-encryption key. Erasure tombstones the refs, nulls the columns and destroys the key, so any copy of that ciphertext, in a dump or a replica, becomes noise.

wrapped_deks · one per subject

Erase rec_…7f2c

The MCP erase tool, gated like reveal and audited as pii.erase, destroys every value for the record (row, drafts, versions, superseded copies). It then shreds the subject's DEKs. One record's erasure never touches anyone else's key.

a backup taken yesterday
restore → decryptwrapped_dek_id: no such key
Honest limits. A backup that also contains wrapped_deks still holds the key until it ages out, so back up the key table on a shorter retention than the values. Values sealed before per-subject keys sit under a legacy org key; erasure destroys them row by row but can't shred that shared key.
06 — AGENT FRONT DOOR

An API no model has seen,
that teaches itself.

Tinker isn't in any model's training data, so the MCP server is designed to be self-describing. An agent with nothing but a tk_ key discovers the schema, learns the governed write paths and does correct work. Agents only ever see the mask.

describe

Objects, fields, lifecycles and allowed operations for this role.

query

Typed, governed reads with filters, keys and blind-index matches.

get_record

One record, projected and masked for the caller.

create_record

Validated, sealed at entry, with an outbox event.

update_record

Governed update; changed fields feed triggers.

transition

Lifecycle moves: draft, review, publish.

render_dashboard

Dashboards through the same compiler and mask.

automationEXPLICIT

Save, list, enable, disable and inspect runs.

request_revealEXPLICIT

File a purpose-bound pii.reveal request.

approvalsEXPLICIT

List, approve or deny, never your own request.

revealEXPLICIT

One field, once, with a second person's approval.

eraseEXPLICIT

Destroy a record's PII and shred its keys.

covered by mcp:toolsneeds mcp:tool:<name> by name, and owner or adminstdio and HTTP/SSE · tinker://ontology/{slug} resources

“If the front door doesn't win clearly, it failed.”

That was the bar Tinker set itself: median time to the first correct publish for a fresh agent, measured against raw HTTP plus human docs. Across three timed, independently verified experiments, adding skill docs on top of the MCP never beat the MCP alone; every verdict was a loss or a tie. The MCP documents itself.

Machine keys that mean something

A tk_ key is bound to one organization and one role at issue time. Sessions take their tenant from the credential, never from the request. Unknown arguments are rejected with a teaching error, not ignored, so an agent learns the real shape instead of guessing.

07 — ARCHITECTURE

29 crates.
Built in gated milestones.

It's one Cargo workspace, layered so the data plane knows nothing about the surfaces built on it. Each milestone has its own test crate and had to pass its gate before the next one began.

MilestonesM0–M8, each with its own gated test crate

M0Ontology → tables, PII split, durable
M1Multi-tenant runtime, auth
M2Governed reactivity
M3Pack ontology
M4Schema evolution
M5Work & comms
M6Governed ingestion
M7Context, agents, cost
M8Authority transfer

Data planeWhere governance lives

tinker-coretenant, ids, blind index tinker-ontologyschema → DDL, sealing tinker-querytyped compiler tinker-dbtwo stores tinker-vaultPII projector tinker-searchone search contract

IdentityWho, and with what assurance

tinker-authWebAuthn, OIDC, tk_ keys tinker-identitysessions, grants

SurfacesEverything built on the plane

tinker-webAxum, schema builder tinker-appsversioned apps tinker-liveSSE grids tinker-commsemail outbox tinker-agentscontext, approvals tinker-ingestconnectors, master data tinker-evolvecanary, promote tinker-durableworkflows on PG tinker-packsinstallable bundles tinker-transferstrangler migration tinker-automatekeyed automations

Front doorFor agents and terminals

tinker-mcpstdio + HTTP/SSE tinker describeself-describing ontology tinker-clipii verify · retrofit · sweep
08 — EVIDENCE

Every claim
names its test.

The status ledger ties each capability to a passing test, and the important ones were checked by breaking the code on purpose to see the test fail. Here are some that guard what this page claims.

  • ✓
    sensitive_values_are_sealed_masked_findable_and_revealable

    No plaintext in the row or the audit log, exact match through the index, and plaintext only through reveal.

  • ✓
    reveal_needs_a_fresh_second_person_approval_for_that_field

    Self-approval, replay and field substitution are all refused.

  • ✓
    erasure_crypto_shreds_copies_of_the_ciphertext

    The subject's key is gone, and a restored copy of the ciphertext is refused.

  • ✓
    sealed_conditions_route_and_actions_resolve_only_at_send

    Keyed conditions match, and the recipient is resolved only at send.

  • ✓
    runs_fail_closed_when_the_author_loses_the_role_and_stop_when_disabled

    An automation never outlives its author's authority.

  • ✓
    webauthn_register_then_login_binds_origin_and_assurance

    Phishing origins, counter replay and UV assurance are covered.

  • ✓
    oidc_token_abuse_fails_closed

    PKCE, single-use state and nonce binding. A bare id_token is refused.

  • ✓
    ingest_seals_sensitive_fields_and_scrubs_landing

    Imported PII is sealed at promotion and scrubbed from the landing tables.

662 / 663

Full suite on PostgreSQL 18 with a real browser test, clean under fmt and clippy -D warnings. The one test not run is a timing benchmark, ignored by design. pii verify runs as a gate.

24h

A soak test of 4.1M operations over both MCP transports, plus backup and point-in-time-recovery drills with measured RPO/RTO, and an internal adversarial review that found and fixed a real DoS.

Not yet

  • No external security audit. The review was internal and is labeled that way.
  • Nothing is deployed publicly. The deploy/ kit is validated locally.
  • Servers queue automation email, but no real provider is set up.
  • Automation actions don't share a transaction across a failed run.
09 — RUN IT

Four commands
to a green suite.

tinker — zsh
# private PG18 cluster on :5440 (macOS or Linux), dev secrets, migrations
$ bin/dev-db.sh
$ eval "$(bin/dev-db.sh env)"

# full suite (lean build), browser test, then `pii verify`
$ bin/test.sh
test: passed 662 failed 0 ignored 1
test: pii verify ok

# the agent front door over stdio (`serve` for HTTP/SSE)
$ TINKER_API_KEY=tk_… cargo run -p tinker-mcp

You'll need

rust
stable toolchain via rustup
postgres
18 (Homebrew postgresql@18 or apt.postgresql.org)
redis
started by the test script on :16390
disk
~4 GB for a lean test build

Then read sealed fields, automations and the threat model.

Give people the tools.
Keep the data.

An internal-tool builder where governance is the kernel, not a feature you remember to turn on.