1. guides
prorm
  • prorm documentation
  • Known test gaps
  • Architecture
  • Core concepts
  • Database types & coverage
  • Getting started
  • Going further
  • compliance
    • Compliance
    • The security toolkit: `prorm audit`
    • Checklists by regulation
    • Declaring personal data
    • Evidence bundles
    • Evidence
    • Frameworks: `@Security`
    • Jurisdictions
    • Platform policies
    • Protecting data
    • The regulation dataset
    • Residency and cross-border transfers
    • Retention & integrity
    • Status
    • Subject rights
  • decorators
    • prorm with decorators
    • Models with decorators
    • Querying a decorated model
    • Relations with decorators
  • diagrams
    • Diagram generators
    • Chen diagram
    • Class diagram
    • Dependency diagram
    • ER diagram
    • Flow diagram
    • From your models to a diagram
    • Gantt diagram
    • Index diagram
    • Migration diagram
    • Model diagram
    • Package diagram
    • Crow’s-foot diagram
    • Schema documentation
    • Sequence diagram
    • State diagram
    • Tree diagram
  • dialects
    • SQL dialects
    • AlloyDB Dialect
    • AlloyDB Omni Dialect
    • Amazon Athena Dialect
    • Aurora DSQL Dialect
    • Amazon Aurora MySQL Dialect
    • Amazon Aurora PostgreSQL Dialect
    • Azure Database for MySQL Dialect
    • Azure Database for PostgreSQL Dialect
    • Azure SQL Database Dialect
    • Azure SQL Managed Instance Dialect
    • Google BigQuery Dialect
    • Citus Dialect
    • ClickHouse dialect
    • Cloud Authentication (`cloud-auth`)
    • Cloud SQL for SQL Server Dialect
    • Cloud SQL for MySQL Dialect
    • Cloud SQL for PostgreSQL Dialect
    • CockroachDB Dialect
    • Azure Cosmos DB for PostgreSQL Dialect
    • CrateDB Dialect
    • Cloudflare D1 Dialect
    • Databricks dialect
    • IBM Db2 Dialect
    • Dolt Dialect
    • Apache Doris Dialect
    • Druid Dialect
    • DuckDB Dialect
    • Exasol dialect
    • Microsoft Fabric Data Warehouse Dialect
    • Firebird Dialect
    • Greenplum Dialect
    • SAP HANA Dialect
    • MySQL HeatWave Dialect
    • Azure Data Explorer (Kusto) Dialect — T-SQL
    • MariaDB Dialect
    • Materialize Dialect
    • MonetDB Dialect
    • MotherDuck Dialect
    • Microsoft SQL Server Dialect
    • MySQL Dialect
    • Neon Dialect
    • OceanBase Dialect
    • openGauss Dialect
    • Oracle Dialect
    • Oracle Autonomous Database Dialect
    • ParadeDB Dialect
    • PolarDB Dialect
    • PostgreSQL Dialect
    • Presto Dialect
    • QuestDB Dialect
    • Aurora RDS Data API Dialect (`rdsdata`)
    • Amazon RDS for Db2 Dialect
    • Amazon RDS for MariaDB Dialect
    • Amazon RDS for SQL Server Dialect
    • Amazon RDS for MySQL Dialect
    • Amazon RDS for Oracle Dialect
    • Amazon RDS for PostgreSQL Dialect
    • Amazon Redshift
    • Amazon Redshift Data API Dialect
    • RisingWave Dialect
    • rqlite Dialect
    • SingleStore Dialect
    • Snowflake Dialect
    • Google Cloud Spanner Dialect
    • Spanner (PostgreSQL interface) Dialect
    • SQLite Dialect
    • StarRocks Dialect
    • Supabase Dialect
    • Azure Synapse Analytics (dedicated SQL pool)
    • Teradata Dialect
    • TiDB Dialect
    • TimescaleDB Dialect
    • Trino Dialect
    • Turso / libSQL Dialect
    • Vertica dialect
    • Vitess Dialect
    • Yellowbrick Dialect
    • YugabyteDB Dialect
  • guides
    • Access rules and behaviour profiles
    • ArangoDB (AQL)
    • Associations
    • Audit levels and escalation
    • Audit logging
    • In the browser
    • Bulk operations
    • Caching
    • Capabilities, tiers and team roles
    • CLI
    • Cloudflare
    • Compliance & data protection
    • Connection Pooling & Performance
    • Security in the console
    • Couchbase: structured N1QL queries
    • Data types
    • Decorators
    • Eager loading
    • Enums and constants
    • Error handling & error types
    • Extensions
    • External fields
    • Foreign data wrappers
    • Graph databases
    • Hooks & lifecycle events
    • Indexes & constraints
    • InfluxDB: structured Flux pipelines
    • klock-errors
    • klock — the `.klock` file
    • Local databases (`prorm db up`)
    • Structured log search — Splunk, Graylog, Papertrail, Sumo Logic
    • Logging
    • Grafana Loki: structured LogQL queries
    • MCP server
    • Migrate — the data transfer subsystem
    • Migrations & Schema Sync
    • Models from an existing database
    • Defining models
    • Multiple databases
    • OrientDB, Neptune and TigerGraph
    • Passwords
    • Importing a Prisma schema
    • Structured PromQL and MetricsQL
    • Query firewall
    • QueryInterface
    • Query operators reference
    • Query optimization
    • Composing find options with `pipe()` and `Q`
    • Query safety: the fail-closed WHERE guard
    • Querying
    • Raw Queries & Query Building
    • Read replicas
    • Schema diffing & migration generation
    • Views, triggers & other schema objects
    • Scopes & default scopes
    • Seeders
    • SPARQL / RDF triplestores
    • SQL constants
    • SQL features
    • SQL function builders
    • SQLite advanced features
    • StoreKey — a prod-orm database as the storage
    • Streaming results
    • Terraform
    • Transactions & Isolation Levels
    • Type mapping: legacy and modern
    • TypeScript types
    • Database users, roles & privileges
    • Validation & constraints
    • Virtual & Computed Fields
    • Writing docs
  • javascript
    • prorm in JavaScript
    • Models in JavaScript
    • Querying in JavaScript
    • Relations in JavaScript
  • runbooks
    • Operational runbooks
    • Connection failures
    • Credential rotation
    • Failover
    • Incident checklist
    • Deploying klock v2
    • Migration rollback
  • running-databases
    • Running databases in Docker
    • Running prorm under Bun in Docker
    • Running Cassandra in Docker with prorm
    • Running ClickHouse in Docker with prorm
    • Running CockroachDB in Docker with prorm
    • Running prorm under Deno in Docker
    • Running Elasticsearch in Docker with prorm
    • Running MariaDB in Docker with prorm
    • Running MongoDB in Docker with prorm
  • prorm control plane
    • Introduction
    • Authentication
    • Errors
    • Getting started
      • Service
        • Is it up
        • This description
    • Account
      • Sign-up and verification
        • Create an account
        • Exchange credentials for a token
        • Verify with the emailed code
        • Verify by clicking the emailed link
        • Name the parent or guardian
        • What a consent link is about
        • Give consent for a child's account
        • Ask the parent or guardian again
        • Send a new code and link
      • Family
        • Everyone this account is responsible for
        • One child, in detail
        • Turn a limit on or off
      • Profile
        • Who am I
        • Change the account
        • Every address on the account
        • Add an address
        • Confirm an address with the code mailed to it
        • Mail a fresh code to an unconfirmed address
        • Make an address primary
        • Remove an address
        • What this caller may do
    • Security
      • Sessions
        • Everywhere this account is signed in
        • Sign out everywhere else
        • Sign one out
        • A token for the CLI on a named machine
        • A token for the customer console, from the staff one
        • Park a sign-in for the CLI to collect
        • The CLI collecting its sign-in
        • The team's members' live sessions
        • Sign a member's session out
        • Sign an account out everywhere
        • Everywhere the account is signed in
        • Sign an account's session out
      • Single sign-on
        • Begin a single sign-on
        • The provider's return
        • Link the signed-in account to a team's provider
        • Which team an address signs in to
        • The team's single sign-on, as a member sees it
        • Set the team's OpenID Connect provider up, or change it
        • Remove the provider
        • The team's domain claims and the DNS record each needs
        • Prove a claimed domain
      • MFA and passwords
        • Start enrolling a second factor
        • Prove the app has it
        • Remove the second factor
        • Ask for a reset link
        • Set a new password with the emailed token
      • Klock
        • Open a device-bound klock session
        • Check in, for the next grant
        • Step up the session asking
        • Rotate the device key
        • Freeze a session
        • Unfreeze a session
        • A guest grant
        • The revocation feed
        • The published keyset
        • What this control plane supports
      • Devices
        • Your devices, or a team's
        • Approve a new device's pairing code
        • One device
        • Rename a device
        • Revoke a device
        • Suspend a device
        • Reinstate a suspended device
        • Wipe a device
        • Ask a device to rotate its key
        • The team's members' devices
        • Revoke a member's device
        • An account's devices
        • Revoke an account's device
        • Wipe an account's device
        • The device fleet
      • Access rules and behaviour
        • The access rules that bear on you
        • Add an access rule
        • This request, as the rules see it
        • One access rule
        • Remove an access rule
        • The behaviour profiles that bear on you
        • Add or change a behaviour profile
        • The profile presets
        • Your behaviour findings
        • Acknowledge a finding
        • One behaviour profile
        • Remove a behaviour profile
        • The team's access rules
        • Add a team access rule
        • Remove a team access rule
        • The team's behaviour profiles
        • Add or change a team behaviour profile
        • Remove a team behaviour profile
        • What the team's profiles caught
        • Acknowledge a team finding
      • Recovery and lockdown
        • Ask for a recovery link
        • Recover access with the emailed code
        • Is the account locked down
        • Lock the account down
        • Release a lockdown
        • Lock a member down
        • Release a member's lockdown
      • MCP tokens
        • Make a delegated token for one MCP client
        • The account's delegated tokens
        • Revoke a delegated token
        • What a delegated token may do now
        • Run one MCP tool with a delegated token
        • Run a device-signed MCP tool call relayed by the MCP Worker
      • Kerberos
        • Give the team its own Kerberos realm
        • The team's realm
        • Remove the team's realm
    • Logging and monitoring
      • Audit log
        • What has been done to the account
        • What has happened to this account
        • The account's audit trail
      • Operations
        • Everything that has been asked of this database
        • One, with its steps
        • Stop it
        • Put a failed one back in the queue
        • Every operation, across all accounts
      • Metrics and events
        • Every measurement, for a graph
        • Who did what to it
    • Billing
      • Plans and pricing
        • What the plans allow
        • The rate card
        • What one database would cost
        • What this account is paying for, itemised
      • Subscriptions and invoices
        • What this month's invoice will say
        • What this account is on
        • What it has been charged
        • Start a Stripe Checkout session
        • Open the customer portal
        • Stripe's events
        • Buy more connection pools
      • Team billing
        • The team's billing account
        • What the team has been charged
        • What this month's invoice will say
        • The team's cards
        • Add a card, through a Stripe-hosted setup session
        • Make a card the team's default
        • Remove a card
        • Start the team paying
        • Open the Stripe portal on the team's customer
        • Change the team's plan
        • Who receives the team's bills
        • How the team pays for transfers
    • Support
      • Support chat
        • My conversations
        • Start a conversation
        • Every support conversation, open first
        • A ticket for the socket
        • One conversation, with who is in it and who is connected
        • Rename a group; open or close a support conversation
        • The lines, oldest first
        • Say something
        • Add somebody
        • Mark read
    • Administration
      • Users
        • Every account
        • One account, with everything hanging off it
        • Grant or revoke admin
        • Verify an account by hand
        • Lock or unlock sign-in
        • Allow or hold provisioning
        • Send the verification mail again
        • Set the plan by hand
      • Platform
        • Every database and store, across all accounts
        • The counts worth putting on a dashboard
      • Staff and permissions
        • What kind of admin the caller is, and which scopes they hold
        • The admin presets
        • Every admin, and the scopes each resolves to
        • Make an account an admin of a kind
        • Change an admin's kind, or a custom admin's scopes
        • Revoke admin
      • Team administration
        • Every team, with its owner and size
        • One team in full — members with their accounts, and projects
        • Hand the team to another member
        • Put an account in the team, active at once
        • Change a member's role
        • Remove a member
      • Assumed identities
        • Assume an identity — act as a customer account
        • The identities the caller holds open
        • End an assumed identity
      • Support inbox
        • The platform's addresses
        • Add a platform address
        • One address, with its counts
        • Change an address's label, routing, or whether it accepts mail
        • Remove an address that holds no mail
        • Make, adopt or repair the address's routing rule
        • Every admin's personal address, made where missing
        • File a raw message, as if it had arrived
        • The inbox
        • One message, whole
        • Mark a message
        • Hand a message to an admin, or take it back
        • Leave an internal note
        • Reply, from the address the message came to
        • What is waiting
    • Databases
      • Databases
        • Data stores
          • Every data store on the account
          • Provision a data store
          • Change a data store that is already running
          • One data store
          • Deprovision it
          • Report what it is using
          • How to reach a data store
        • Connections and credentials
          • The api keys on a database
          • Issue a key
          • Revoke a key
          • The engine users we created
          • Create a user inside the database
          • Give a user a new password
          • Drop a user
          • How the database is reached
          • Set how it is reached
          • Revoke every api key on it
          • How to connect to it
          • The ssh keys that open its bastion
          • Add a key, or have one made
          • The private half of a generated key, once
          • Revoke an ssh key
        • Schema and objects
          • The SQL objects we put in it
          • Create one, and apply it
          • What is in the database, as the engine reports it
          • One table
          • What it would run, without running it
          • Redefine an object in place
          • Drop it
          • The extensions installed in it
        • Migrations
          • A plan compiled to statements, recording nothing
          • The migrations recorded for it
          • Record one
          • Apply everything recorded and not yet run
          • Apply everything pending, oldest version first
        • Lifecycle
          • Grow or shrink it
          • Stop it accepting connections
          • Start it again
          • Move it to another region
        • Backups and replicas
          • Take a backup
          • The backups it has
          • Restore one into it
          • What has been restored into it, and from where
          • Its read copies
          • Add one
        • Policies
          • The catalog
          • One policy
          • What this database is held to
          • Switch a policy on, or change its knobs
          • Switch it off
          • Mark the policies reviewed
          • Evaluate the periodic policies now
          • Who is behind a credential
        • Passwordless
          • The database's passwordless sessions
          • Issue a passwordless session (v1)
          • Revoke a passwordless session
          • Bind a session to a device key
          • Issue a v2 session for a host to bind
          • Redeem an enrolment ticket on the host
          • Check a passwordless credential
          • Run one described query under a passwordless credential
        • Database roles
          • The database's roles
          • Define a role
          • One role
          • Change what a role permits
          • Delete a role, and revoke every key granted it
          • Grant a role, which mints a key
          • Who holds a role
          • The api keys bound to a role
        • Pools
          • A database's connection pools, and how busy each is
          • Open another pool
          • Rename a pool or change its size
          • Close a pool
        • Data plane
          • Run a query against a database
          • Several statements, in order, over one connection
          • Is this database answering
          • What the proxy itself is holding
        • Transfers
          • The strategies this account's tier may use
          • Price a transfer without creating it
          • The account's transfer jobs, newest first
          • Create a transfer job
          • One job, and whether its certificate holds
          • Cancel an unfinished job
          • Start a created job, confirming its price
          • Report a job's status (the runner, or the owner's side of a local run)
        • Every database on the account
        • Provision a database
        • Change a database that is already running
        • One database
        • Deprovision it
        • Read or write it
        • Report what it is using
        • The engines this API will provision
        • Where a managed database can be put
    • Teams
      • Teams
        • Members
          • Who is on it
          • Invite somebody
          • Accept an invitation
          • Change somebody's role
          • Remove somebody
        • Projects
          • The projects a team runs
          • Start one
          • Rename a project, or move its environment
          • Delete a project
          • One project, and what is in it
          • Put a database or store in it
          • What is in a project
          • Take one out
        • The teams this account is on
        • Start one
        • The twelve roles, and what each may do
        • Rename a team
        • Close a team
        • One team
        • Hand the team to somebody else
        • Leave a team
    • Schemas
      • Error
      • ValidationErrors
      • Token
      • PolicyCategory
      • PolicySeverity
      • PolicyAction
      • PolicyTrigger
      • PolicyParam
      • Policy
      • PolicyCatalog
      • PolicyAssignment
      • PolicyOutcome
      • PolicyFiring
      • CredentialTrace
      • Relation
      • Restrictions
      • ChildSummary
      • SignupRequest
      • SigninRequest
      • UpdateMeRequest
      • User
      • Address
      • GeoPoint
      • D1Region
      • Usage
      • UsageReport
      • Resource
      • Database
      • DataStore
      • NewDatabase
      • NewDataStore
      • QueryRequest
      • DatabasePrivilege
      • ApiKey
      • DbUser
      • Access
      • AccessRequest
      • DatabaseObject
      • NewDatabaseObject
      • NewMigration
      • SchemaOperation
      • Migration
      • Operation
      • Session
      • AdminPowers
      • Admin
      • AuditEntry
      • TeamRole
      • Conversation
      • ChatMessage
      • TeamSso
      • Team
      • TeamMember
      • SshKey
      • ConnectionInfo
      • InboxAddress
      • InboxMailbox
      • InboxMessageSummary
      • InboxMessage
      • InboxNote
      • Page
      • Quote
      • Plan
      • ApiError
      • CapabilityRefusal
      • KlockErrorCode
      • PageWindow
      • SessionView
      • KlockStepUpRequest
      • KlockOpenRequest
      • KlockOpenedSession
      • UpdateMeResult
      • CapabilitiesView
      • SessionLimits
      • Device
      • DeviceListed
      • DeviceTeamRef
      • DeviceCommand
      • DeviceAuditLine
      • DeviceActionRequest
      • StaffActionRequest
      • ManagedSession
      • SessionRevoked
      • AccessRule
      • AccessRuleRequest
      • BehaviourProfile
      • BehaviourProfileRequest
      • BehaviourProfileSaved
      • BehaviourFinding
      • BehaviourFindingAck
      • StepUpProof
      • Removed
      • MemberLockdownRequest
      • McpClient
      • McpDelegatedView
      • McpToolCall
      • McpForwardRequest
      • PasswordlessScope
      • RoleScope
      • PasswordlessSession
      • PasswordlessTlsMaterial
      • PasswordlessKerberosMaterial
      • ServiceDeviceKeys
      • ServiceSession
      • DatabaseRole
      • TransferEndpoint
      • TransferJobRequest
      • TransferQuote
      • TransferStrategy
      • TransferJob
      • TransferJobAccess
      • Pool
      • PoolAllowance
      • BillingSubscription
      • Invoice
      • BillingContact
      • PaymentMethod
      • UpcomingCharges
      • TeamBilling
      • TeamKerberosRealm
      • TeamSsoDomain
      • TeamSsoResponse
      • StoreConnection
      • BatchFailure
      • ProxyStatus
  1. guides

Access rules and behaviour profiles

klock v2 has two ways to say where from and how an account may be used,
on top of what its capabilities allow:
An access rule allows or blocks one property of a request: its address,
country, network, device, client certificate, Kerberos principal, time of
day, client program or API key. Rules are attached to an account, a team, a
database, a store or a device. A rule either admits a request or refuses it.
A behaviour profile describes what normal looks like for a session, a
device or a service: where it connects from, at what hours, on which
devices, through which clients, and how much it does. When a request or a
sync strays from that, the profile raises a signal, and the signal maps
to actions: log, alert, step-up, rate-limit, freeze or revoke.
Both are evaluated by pure, shared code in the ORM, so the API, the klock
service, the MCP Worker and the CLI reach the same answer from the same data.
This page describes that engine, the CLI commands that use it, and how the
API applies it on every request.
WhereWhat
src/klock/policy/access-rules.tsevaluateAccess, wouldLockOut, compileRules, validateAccessRule, and the value parsers (parseCidr, parseAsn, parseClientSelector, parseTimeWindow, …).
src/klock/policy/profiles.tsvalidateProfile / defineProfile, resolveProfile, evaluateSignals, findingToEscalationEvent, toLocalPolicy / evaluateLocal, SIGNAL_DEFAULTS, ACTION_LEVELS.
src/klock/policy/presets.tsThe seven built-in profile presets (PROFILE_PRESETS, listProfilePresets, getProfilePreset).
src/klock/policy/profile.schema.jsonThe profile JSON Schema (https://prod-orm.io/schemas/klock-profile.v1.json).
src/logging/escalation/presets.tsThe klock-behaviour escalation preset that profile findings route through.
src/cli/basic/commands/{access,behaviour,lockdown}.tsprorm access …, prorm behaviour … and prorm lockdown.
All of it is importable from ts-prorm-orm/klock/policy. Nothing in it does
I/O: the caller supplies the rules, the profile, the learned baseline, the
counters and the request.

Access rules#

A rule#

A rule is one row of the API's access_rules table:
FieldValuesDefaultMeaning
scopeKindaccount, team, database, store, devicerequiredWhat the rule is attached to.
scopeIdan id, or null for the accountnullWhich team, database, store or device (dev_…).
effectallow, blockrequiredAdmit only matching requests (allow), or refuse matching ones (block).
dimensionsee dimensionsrequiredWhich property of the request is matched.
valuedepends on the dimensionrequiredWhat to match.
appliesTocapability patterns (["data:*"]), a JSON list or a comma-separated stringnull (every request)Limit the rule to requests needing one of these capabilities.
mandatoryboolean (or 0/1)true for team, false otherwiseA broader allowlist that narrower scopes cannot widen. See precedence.
modeenforce, reportenforceA report rule never refuses anything; it records what it would have refused.
enabledboolean (or 0/1)trueA disabled rule is dropped before evaluation.
expiresAtepoch ms, epoch seconds, an ISO string or a Datenull (never)After this moment the rule takes no part.
notetext–Kept with the rule.
validateAccessRule(rule) returns the problems with one rule (an empty list
means it is valid), and compileRules(rules) pre-parses a whole set once so
evaluation does no parsing. By default an invalid rule makes compileRules
throw an AccessRuleError listing every bad rule. With { lenient: true }
the invalid rule is kept but never matches. An invalid allow rule therefore
still creates its allowlist, which fails closed.

Dimensions and values#

The engine parses each value as follows. Anything else is refused by
validateAccessRule.
DimensionValueMatched againstNotes
ipAn IPv4 or IPv6 address, or a CIDR (203.0.113.0/24, 2001:db8::/32)The client addressIPv4 is held as IPv4-mapped IPv6, so 0.0.0.0/0 matches every IPv4 address and ::/0 every address. Host bits are cleared. Octets with leading zeros (010) are refused, because parsers disagree on whether they are octal. Brackets and zone ids are tolerated in the request address.
countryISO 3166 alpha-2 (GB)The request's countryCase-insensitive. Three-letter codes are refused.
asnAn AS number: 13335, AS13335 or as13335The request's network0 to 4294967295.
deviceA device id (dev_…)The device that signed the requestExact match.
certA certificate SHA-256The client certificate's fingerprint64 hex characters. Colons, spaces, case and a sha256: prefix are ignored.
principalA Kerberos principal, with optional * wildcards (*@EXAMPLE.COM)The request's principalCase-sensitive, because Kerberos names and realms are.
timeA time window: { "days": [1,2,3,4,5], "start": "08:00", "end": "19:00", "timezone": "Europe/London" }, as an object or JSONThe time of the requestThe escalation TimeWindow shape: days 0 (Sunday) to 6, HH:MM from 00:00 to 24:00, an IANA timezone. Every key is optional.
clientA client selector: cli, mcp, mcp:name=Claude Desktop, mcp:name=Claude*The calling programThe type and attribute keys are case-insensitive; * globs are allowed in the type and in values. Several attributes are comma-separated (mcp:name=X,version=2).
api-keyAn API key prefix (prk_ab12cd34), optionally written api-key:prk_ab12cd34The key's prefixExact match.
A client name reported over MCP (clientInfo.name) is chosen by the client
itself. Treat a client rule on it as advisory, not as proof of which
program is calling.
A request that carries no value for a dimension (no address, no device,
no certificate) fails every allowlist on that dimension and matches no block
rule on it. time always has a value: the request's time.

Precedence#

evaluateAccess(rules, ctx, opts?) decides one request:
1.
Filter. Only rules that are enabled, not expired, not in a bypassed
scope, and whose appliesTo covers the request's capabilities take part.
2.
Block beats allow. Any matching block rule, in any scope, denies the
request.
3.
Allowlists, one dimension at a time. For each dimension that has
allow rules, the most specific scope that has any wins, and the request
must match one of that scope's rules. Scopes rank:
device (4) > database = store (3) > team (2) > account (1). When
several scopes tie for most specific (a database and a store, or two
teams), each of them must be satisfied.
4.
Mandatory allowlists intersect. Every broader scope with mandatory
allow rules for that dimension must also be satisfied. A broader scope's
non-mandatory allow rules are ignored once a narrower scope has allow
rules for the dimension.
5.
Report mode. The decision uses enforce rules only. The set is then
evaluated again with the report rules counted. If that second pass would
deny and the first allows, the result carries access.would-deny findings
in reportOnly, and its mode is report.
Dimensions are independent: a request must pass the allowlists of every
dimension that has any.
The practical upshot:
A member cannot widen a team's allowlist: team rules are mandatory by
default, so a database's own allowlist is checked and the team's.
A user can make one database more permissive than their own account
default: account rules are not mandatory by default, so the database's
allowlist replaces the account's for that database.
Nothing can override a block. To make an exception to a block, narrow the
block (a smaller CIDR, appliesTo, an expiry), not add an allow.

The decision#

evaluateAccess returns:
FieldMeaning
decisionallow or deny.
modeenforce, or report when the request is allowed only because the rules that would deny it are in report mode.
reasonOne line: allowed, the deny reasons joined, or allowed (report mode would deny: …).
reasonsFor a deny, one entry per failure: code (blocked, not-allowlisted, mandatory-allowlist or missing-context), dimension, scopeKind, scopeId, ruleIds, mode, message.
ruleFor a deny, the deciding rule: the block that matched, or the first allowlist that was not met.
matchedThe blocks that fired, or the allows that admitted the request.
reportOnly{ event: 'access.would-deny', reason } for each report-mode failure.

appliesTo#

A rule with appliesTo takes part only when the request needs one of the
listed capabilities. Patterns are *, ns:* (for example data:*) or an
exact capability id. The request states its capabilities in ctx.caps:
caps undefined means "unknown", and every rule applies. This fails
closed: a caller that does not know what the request needs gets the
strictest answer.
caps: [] means "needs nothing", and only rules without appliesTo apply.

Worked examples#

Each result below is the real output of evaluateAccess for the rules and
request shown. The time is Monday 28 September 2026, 10:00 UTC, unless noted.
1. Block beats allow. The account allows 203.0.113.0/24; the database
app blocks 203.0.113.7.
RequestDecisionReason
ip 203.0.113.7deny (blocked)blocked by ip rule d1 (database app)
ip 203.0.113.8allowallowed
2. The most specific allowlist wins. The account allows country GB
(not mandatory, the default); the database app allows FR. For requests to
app, the database's allowlist replaces the account's.
RequestDecisionReason
country FRallowallowed
country GBdeny (not-allowlisted)not in the country allowlist of database app
3. A team allowlist is mandatory. The team acme allows GB; the
database app allows FR and GB. The team's rule is mandatory by default,
so the database cannot add France.
RequestDecisionReason
country FRdeny (mandatory-allowlist)not in the mandatory country allowlist of team acme
country GBallowallowed
4. Dimensions are independent, and a missing value fails. The account
allows 198.51.100.0/24; the device dev_A allows the client cli.
RequestDecisionReason
ip 198.51.100.9, client cliallowallowed
ip 198.51.100.9, client mcp (name Claude Desktop)deny (not-allowlisted)not in the client allowlist of device dev_A
no ip, client clideny (missing-context)the request carries no ip of account
5. Report mode. The account blocks FR, in report mode.
RequestDecisionModeReason
country FRallowreportallowed (report mode would deny: blocked by country rule blk-xx (account))
6. appliesTo and time. The account allows weekdays 08:00–19:00
(Europe/London), for data:write and resource:delete only.
RequestDecisionReason
Saturday 23:00 London, caps: ['data:read']allowthe rule does not apply
Saturday 23:00 London, caps: ['data:write']deny (not-allowlisted)not in the time allowlist of account
Monday 11:00 London, caps: ['data:write']allowallowed
Saturday 23:00 London, caps unknowndeny (not-allowlisted)unknown capabilities: every rule applies
7. Expiry and enabled. A block on 0.0.0.0/0 that expired on
1 January 2026, or one with enabled: 0, takes no part: the request is
allowed.
In code:
import { evaluateAccess, type AccessRule } from 'ts-prorm-orm/klock/policy';

const rules: AccessRule[] = [
  { id: 'team-gb', scopeKind: 'team', scopeId: 'acme', effect: 'allow', dimension: 'country', value: 'GB' },
  { id: 'db-fr', scopeKind: 'database', scopeId: 'app', effect: 'allow', dimension: 'country', value: 'FR' },
  { id: 'db-gb', scopeKind: 'database', scopeId: 'app', effect: 'allow', dimension: 'country', value: 'GB' },
];

const d = evaluateAccess(rules, { country: 'FR', client: 'cli', caps: ['data:read'] });
console.log(d.decision, d.reasons[0]?.code); // deny mandatory-allowlist

Report mode#

Every rule has a mode. A report rule never changes a decision; it
produces access.would-deny findings in reportOnly when enforcing it would
have denied the request. Use it to watch a new rule for a while before it
refuses anything.
On the API, every new rule starts in report mode for 24 hours and then
enforces, unless you pass --enforce-now (see the API).
--report-only keeps a rule in report mode until you change it.

The lock-out guard#

A rule that denies the person saving it locks them out of their own account.
wouldLockOut(rules, actorCtx, opts?) is the save-time check. It evaluates
the proposed rule set (the actor's rules after the change) against the
actor's own request, with report-mode rules treated as enforced, because
a new rule that starts in report mode becomes enforced by itself later. It
returns:
FieldMeaning
lockedOutTrue when the set, fully enforced, would deny the actor.
deniedNowTrue when it denies the actor right now (enforce-mode rules only).
reasons, ruleIdsWhy, and the rules responsible.
For example, adding an account-scope allow country US in report mode, from
a request in GB, gives lockedOut: true, deniedNow: false, with the reason
not in the country allowlist of account.
prorm access allow|block|add runs this guard locally before it sends the
rule:
It asks the API how it sees this CLI's request
(GET /security/access-rules/context: address, country, network), adds
this CLI's device id and the client cli, and checks only the rules it can
judge: the account's, this device's, and the current team's (--team), on
the dimensions it has a value for.
When the rule would lock you out, it refuses with
Refusing to save: this rule would lock you out of your own account. and
exits non-zero ({ "error": "would_lock_you_out", … } with --json).
--force saves it anyway, after a confirmation. The request carries
force: true, which the API accepts only with a fresh step-up and a
verified email address.
From a laptop in London:
$ prorm access allow country US
✗ Refusing to save: this rule would lock you out of your own account.
  not in the country allowlist of account
Fix the rule, or pass --force (the API asks for a step-up).
$ prorm access allow country GB
✓ Allowed country GB on account:42 (rule 5b0e2c1a-…).
It starts in report mode (24 hours) unless --enforce-now: findings only, nothing refused.
The API runs the same guard, with the full rule set, and answers 409 would_lock_you_out. The CLI reports that the same way.
[!CAUTION]
--force is only as safe as your email. The API accepts a forced save only
with a fresh step-up and a verified address, because
recovery is by email. Check that
prorm whoami shows the address you can still read before forcing anything.

Bypassed scopes and recovery#

evaluateAccess and wouldLockOut take bypassScopes: scopes whose rules
are skipped. The API uses it for two exemptions:
An account recovery session bypasses account rules.
A team owner, on the team's own security management routes, bypasses
team rules (not on data routes).
For example, the account-scope allow country US rule above, evaluated with
{ bypassScopes: ['account'] } from GB, allows the request.
The recovery flow itself (an emailed one-time code, then password and the
second factor, giving a one-hour recovery session that skips account rules)
is described under recovery.
prorm lockdown is the other side of recovery: after a scare, it revokes
every interactive, device, delegated and browser session on the account
(including the CLI's own), suspends every device, and refuses every request
of the account for 15 minutes (see lockdown). --include-services also revokes service sessions (databases, CI
and agents stop working). It asks you to type LOCKDOWN (or pass --yes) and
sends a TOTP step-up when the API asks for one. prorm lockdown release
ends it. Both call /security/lockdown (POST and DELETE).

The CLI#

CommandDoes
prorm access list [--scope …]Lists rules: scope, effect, dimension, value, mode, appliesTo, expiry, note. Alias prorm access.
prorm access show <id>One rule in full.
prorm access allow <dimension> <value> · prorm access block <dimension> <value>Adds a rule, after validating it and running the lock-out guard.
prorm access add <allow|block> <dimension> <value>The same, with the effect as an argument.
prorm access rm <id>Removes a rule, after a confirmation. Alias access remove.
prorm access test [--ip … --country … --asn … --device … --client … --caps … --at …]Evaluates your rules locally with evaluateAccess, for this CLI's own request by default, or for the one described by the flags. Exits non-zero on a deny, and prints report-mode would-denies.
--scope is account (the default), team:<t>, db:<d> (or
database:<d>), store:<s> or device:<id>. The rule flags are --until
(a duration such as 24h or 7d, or a date), --caps (the appliesTo
patterns, comma-separated), --report-only, --enforce-now, --mandatory,
--note and --force. A time value is given as JSON:
Reading needs the capability access-rule:read; allow, block, add and
rm need access-rule:write. Setting mandatory rules for a team needs
access-rule:team in that team. See capabilities.
prorm db access is a different thing: the database's network settings,
including the engine firewall allowedIps. It is unchanged.

Example: a payments team#

The payments team runs a ledger database. Its security lead wants:
IdScopeRuleWhy
uk, ieteamallow country GB, IEThe team works from the UK and Ireland. Team rules are mandatory, so no database or member can widen this.
bad-asnteamblock asn AS64500A hosting network that attacks have come from. A block beats every allow.
vpnteamallow ip 203.0.113.0/24, for migration:apply, resource:delete and key:writeOnly from the office VPN for the dangerous operations. Reads and ordinary writes work from anywhere in the allowed countries.
hoursteamallow time weekdays 07:00–20:00 London, for migration:apply and resource:deleteNo migrations at the weekend.
no-mcp-writesdatabase ledgerallow client cli, for data:writeAI clients may read the ledger, but may not write to it.

Start in report mode#

Every new rule reports for 24 hours by default, which is what a team-wide
change needs. Add the rules, then watch for access.would-deny in the audit
log before they enforce.
The answer is 201 { "rule": { …, "mode": "report", "enforceAt": "…" } }.
Team rules are on the team page's Access tab (#/teams/:id?tab=access),
and the database rule is on the ledger database's Access rules tab.
Leave enforce now unticked to get the 24-hour report period.

Check them before they enforce#

prorm access test runs the same engine locally. With no flags it judges
this CLI's own request; with flags it judges the one you describe:
$ prorm access test --scope team:payments --country GB --ip 198.51.100.20 --caps migration:apply
✗ Denied: not in the ip allowlist of team payments
$ prorm access test --scope team:payments --country GB --ip 203.0.113.9 --caps migration:apply --at 2026-09-28T10:00:00+01:00
✓ Allowed: allowed
$ prorm access test --scope team:payments --country GB --ip 203.0.113.9 --caps migration:apply --at 2026-10-03T22:00:00+01:00
✗ Denied: not in the time allowlist of team payments

Let them enforce#

After 24 hours, each rule enforces on its own; the hourly cron writes the new
mode to the row. Use --enforce-now for a rule you are sure of, and
--report-only for one you want to watch indefinitely.
What the whole set decides, from evaluateAccess (Monday 28 September 2026,
10:00 London, unless noted):
RequestDecisionReason
Read from home in GB, cliallowallowed
Read from Francedeny (not-allowlisted)not in the country allowlist of team payments
migration:apply from homedeny (not-allowlisted)not in the ip allowlist of team payments
migration:apply on the VPNallowallowed
migration:apply on the VPN, Saturday 22:30deny (not-allowlisted)not in the time allowlist of team payments
data:write from mcp:name=Claude Desktopdeny (not-allowlisted)not in the client allowlist of database ledger
data:read from mcp:name=Claude Desktopallowallowed
Anything from AS64500deny (blocked)blocked by asn rule bad-asn (team payments)
The same set in code, for a test that pins the policy down:
import { evaluateAccess, type AccessRule } from 'ts-prorm-orm/klock/policy';

const office = { days: [1, 2, 3, 4, 5], start: '07:00', end: '20:00', timezone: 'Europe/London' };
const team = { scopeKind: 'team', scopeId: 'payments' } as const;

const rules: AccessRule[] = [
  { id: 'uk', ...team, effect: 'allow', dimension: 'country', value: 'GB' },
  { id: 'ie', ...team, effect: 'allow', dimension: 'country', value: 'IE' },
  { id: 'bad-asn', ...team, effect: 'block', dimension: 'asn', value: 'AS64500' },
  { id: 'vpn', ...team, effect: 'allow', dimension: 'ip', value: '203.0.113.0/24',
    appliesTo: ['migration:apply', 'resource:delete', 'key:write'] },
  { id: 'hours', ...team, effect: 'allow', dimension: 'time', value: office,
    appliesTo: ['migration:apply', 'resource:delete'] },
  { id: 'no-mcp-writes', scopeKind: 'database', scopeId: 'ledger', effect: 'allow',
    dimension: 'client', value: 'cli', appliesTo: ['data:write'] },
];

const monday = Date.parse('2026-09-28T09:00:00Z');
const d = evaluateAccess(rules, {
  ip: '198.51.100.20',
  country: 'GB',
  client: 'mcp:name=Claude Desktop',
  caps: ['data:write'],
  now: monday,
});
console.log(d.decision, d.reason); // deny not in the client allowlist of database ledger
[!NOTE]
The no-mcp-writes rule keys on the credential, not on what the client
says: klock CLI sessions are cli, and delegated tokens and forwarded MCP
calls are mcp. A rule on mcp:name=… would only be advisory, because the
name is the client's own claim.

Behaviour profiles#

The spec#

A profile is JSON, validated against profile.schema.json:
{
  "name": "ops-laptops",
  "extends": "preset:standard-user",          // optional: start from a preset
  "applies": { "kinds": ["interactive"], "tiers": ["admin"], "caps": ["data:*"] },
  "normal": {
    "locations": { "countries": ["GB"], "asns": [13335], "cidrs": ["203.0.113.0/24"], "learn": true, "maxNewPerWeek": 1 },
    "hours": [ { "days": [1, 2, 3, 4, 5], "start": "08:00", "end": "19:00", "timezone": "Europe/London" } ],
    "devices": { "ids": ["dev_…"], "requireKeychain": true, "maxNew": 0, "learn": true },
    "clients": ["cli", "mcp:name=Claude Code"],
    "operations": {
      "rates":   [ { "cap": "data:read", "max": 1000, "per": "1h" } ],
      "volumes": [ { "metric": "rowsRead", "max": 5000000, "per": "1d" },
                   { "metric": "transferBytes", "max": "5GB", "per": "1d" } ]
    }
  },
  "capabilities": { "allow": ["data:read"], "deny": ["resource:delete"] },
  "on": [ { "when": "location.new-country", "do": ["alert", "step-up"] },
          { "when": "rate.exceeded", "do": ["rate-limit"], "rateLimit": { "max": 10, "per": "1m", "for": "15m" } } ],
  "escalation": { "level": "warning", "preset": "klock-behaviour", "tags": ["ops"] }
}
SectionMeaning
appliesWhich requests the profile covers: session kinds, tiers, and capability patterns in caps. A fact the observation does not carry counts as covered, so an incomplete observation is still checked.
normal.locationsDeclared countries, networks (asns) and address ranges (cidrs); learn (default true) widens the learned baseline; maxNewPerWeek caps how fast it widens.
normal.hoursTime windows, in the same shape as a time access rule.
normal.devicesDeclared device ids; requireKeychain flags a device key held in a file; maxNew caps new devices; learn.
normal.clientsClient selectors, as for a client access rule.
normal.operationsrates (a capability, a maximum and a window) and volumes (rowsRead, transferBytes or filesBytes, a maximum and a window). A byte size is SI (5GB) or binary (5GiB); rowsRead takes a count. Durations are 30s, 15m, 1h, 1d and so on.
capabilitiesA subset ceiling applied on top of the grant: a capability that matches deny, or does not match a given allow, raises cap.outside-profile.
onWhich actions a signal leads to. when is a signal, <namespace>.* (for example location.*) or *. The most specific match wins (exact, then namespace, then *); entries of equal specificity combine. A signal with no matching entry uses its default actions.
escalationlevel is a floor for every finding's level; preset is the escalation preset findings route through (default security-failed-access; every built-in preset uses klock-behaviour); tags are added to each event.
validateProfile(input) (an object or a JSON string) returns every problem:
the schema, then what a schema cannot express. That covers timezones, CIDRs,
durations and byte sizes, client selectors and capability ids, the escalation
level and preset, an unknown extends preset, an on entry listed twice, a
namespace.* that matches no signal, and a rateLimit without the
rate-limit action. defineProfile(input) returns the validated spec or
throws a ProfileValidationError listing them.

Presets and extends#

"extends": "preset:<name>" starts from a preset. resolveProfile(spec)
applies the chain: the preset first, then your spec on top. Objects merge key
by key, lists are replaced, on entries merge by when, and
escalation.tags accumulate. An unknown preset or a cycle throws.
No preset names a country, network or timezone, because those are yours.
Locations and devices are learned instead, with maxNewPerWeek and maxNew
capping how fast the baseline may widen. The seven presets, from
src/klock/policy/presets.ts:
PresetApplies toCapability subsetFreezes onRevokes onLevel floor
strict-admintiers: admin; kinds: interactive, browser, device–impossible-travel, agent.tampersync.rollbackwarning
standard-userkinds: interactive, browser, device–agent.tamper, sync.rollback––
ci-servicekinds: servicedeny resource:delete, billing:*, team:*, key:writeclient.unexpected, cap.outside-profilelocation.new-country, location.outside, device.unknown, device.new, agent.tamper, sync.rollbackwarning
read-only-analystkinds: interactive, browser, device (extends standard-user)allow 13 read and self ids; deny data:write, migration:*, resource:delete, resource:provision, files:sendagent.tamper, sync.rollback––
db-gatewaykinds: serviceallow passwordless:use, data:read, data:writelocation.new-country, location.new-asn, location.outside, cap.outside-profiledevice.unknown, agent.tamper, sync.rollbackwarning
on-device-agentkinds: serviceallow transfer:report, passwordless:use, klock:synccap.outside-profiledevice.unknown, agent.tamper, sync.rollbacknotice
guestkinds: guestallow the five public capabilities––info
In more detail:
strict-admin: known devices only (maxNew: 0), keychain-held keys,
learned locations (one new country a week), clients cli, browser and
mcp, and at most 500 data:write and 5 resource:delete an hour. It
steps up on a new country, device or client, a file-held key, leaving the
declared locations, a volume breach or a stale agent. Rate breaches are
limited to 10 a minute for 15 minutes.
standard-user: learned locations (three new a week) and devices
(three new), an alert on a new country or device, step-up on impossible
travel or an unknown device, and 20,000 reads and 5,000 writes an hour.
ci-service: one device, no locations learned, clients cli and api
only, 20 migration:apply and 10,000 data:write an hour.
read-only-analyst: standard-user plus reads only, 5,000,000 rows
read and 5 GB transferred a day. A capability outside the subset steps up;
a volume breach rate-limits.
db-gateway: a passwordless database gateway: a fixed keychain-held
device, clients api and agent, 100,000 passwordless:use an hour and
50,000,000 rows read a day.
on-device-agent: the on-device transfer agent: one keychain-held
device, clients agent and cli, 600 transfer:report an hour; a stale
agent alerts.
guest: anonymous guest device keys: the public capabilities only,
600 docs and reference reads and 120 scaffolds an hour, rate-limited to
30 a minute for 10 minutes when exceeded, and a log entry for everything
else.
prorm behaviour presets lists them, and prorm behaviour presets <name>
prints one in full.

Signals and actions#

evaluateSignals(profile, baseline, observation, opts?) compares one
observation (a request, a sync or a cron sample) with the profile and the
learned baseline. Every observation field is optional, and an absent fact
raises no signal.
SignalSeverity (level)Default actionsRaised when
location.new-countrymedium (warning)alertA country not in the learned baseline.
location.new-asnlow (notice)logA network (AS) not in the learned baseline.
location.outsidehigh (error)alert, step-upOutside the declared countries, networks or CIDRs.
impossible-travelhigh (error)alert, step-upTwo locations too far apart for the time between them.
hours.outsidemedium (warning)logOutside the declared hours.
device.unknownhigh (error)alert, step-upNo device, a device outside the declared list, or past maxNew.
device.newmedium (warning)alertA device not in the learned baseline.
device.file-keylow (notice)logThe device key is held in a file, not the OS keychain.
client.unexpectedmedium (warning)alertA client that matches none of the declared selectors.
cap.outside-profilemedium (warning)logA capability outside the profile's subset.
rate.exceededmedium (warning)rate-limitAn operation rate above its ceiling.
volume.exceededhigh (error)alertRows read or bytes moved above the ceiling.
agent.tampercritical (critical)alert, freezeThe device agent reported tampering (integrity failure).
sync.stalelow (notice)logThe device has not synced within the allowed time (24 hours by default, staleAfter).
sync.rollbackcritical (critical)alert, freezeThe device presented an older sync state than it had.
impossible-travel fires above 1,000 km/h (maxTravelKmh) for jumps of at
least 500 km (minTravelKm), from the baseline's lastSeen sighting and the
observation's geo.
The actions, weakest first:
ActionLevelWhat it means
loginfoAn audit entry.
alertwarningNotify the owner or the team's admins.
step-upwarningClear the session's step-up, so the next gated call asks for one.
rate-limitwarningA per-session token bucket: rateLimit from the on entry, else 60 a minute for 15 minutes. When several findings rate-limit, the tightest wins.
freezeerrorEvery request is refused until the session is unfrozen with a step-up.
revokecriticalEnd the session.
The result lists the findings, the actions to apply now, wouldApply
(what enforce mode would do), the strongest action, the highest level,
any rateLimit, and learn: the countries, networks and devices the
caller may add to the baseline. A finding's level is the highest of its
signal's severity level, its strongest action's level (in enforce mode) and
the profile's escalation.level floor.
Report mode. A profile row has a mode too. In report mode the only
action applied is log, and wouldApply still says what enforce mode would
do. The profile is evaluated with { mode: 'report' }.
Learning. A first sighting on an empty baseline seeds it and raises no
signal. After that, a new country, network or device raises its signal and
is offered in learn, unless learn: false, or maxNewPerWeek (locations)
or maxNew (devices) has been reached. Past maxNew, a new device raises
device.unknown instead of device.new.
For example, { "name": "mine", "extends": "preset:strict-admin" } with a
baseline of GB and dev_A, observing an admin's interactive request from
FR on dev_A with a file-held key:
ModeFindingsActionswouldApply
enforcelocation.new-country (medium, warning), device.file-key (low, raised to warning by the floor)alert, step-upalert, step-up
reportthe same two, at warninglogalert, step-up
In both cases learn.countries is ["FR"]: strict-admin allows one new
country a week.
import { defineProfile, evaluateSignals } from 'ts-prorm-orm/klock/policy';

const profile = defineProfile({ name: 'mine', extends: 'preset:strict-admin' });
const result = evaluateSignals(
  profile,
  { countries: ['GB'], devices: ['dev_A'] },
  { kind: 'interactive', tier: 'admin', country: 'FR', deviceId: 'dev_A', deviceKeyStore: 'file', client: 'cli' },
);
console.log(result.findings.map((f) => f.signal), result.actions);
// [ 'location.new-country', 'device.file-key' ] [ 'alert', 'step-up' ]
Rates and volumes are counted by the caller. profileCounters(profile) lists
the counters a profile needs (key, window, ceiling), and the observation
passes the current counts in rates and volumes, keyed by rateKey(rule)
(data:read@1h) or volumeKey(rule) (rowsRead@1d), or by the bare
capability or metric.

Where signals are computed#

The engine is one set of functions, run in four places:
WhereSignals
The API, per requestLocation, hours, device, client and the capability subset; rates through Durable Object counters.
An hourly API cronVolumes, and baseline learning.
The klock service, at synclocation.new-country, location.new-asn, location.outside, impossible-travel, device.unknown, device.new, device.file-key, agent.tamper, sync.stale and sync.rollback: the signals only it sees.
The device agent (advisory)hours.outside, cap.outside-profile and rate.exceeded (LOCAL_SIGNALS), from the compiled local policy in the sync answer's policy.local.
The klock service reports its findings to the API
(ApiInternal.recordFindings) before it mints the grant. The API accepts a
finding only for a profile that applies to that device, re-derives its
severity and actions from the profile, and records it once per profile,
signal and device every 6 hours. In enforce mode it applies the actions to
the device's session, so a freeze or revoke means that same sync gets no
grant. See policy at sync.
The device agent's check is advisory: toLocalPolicy(profile) compiles the
subset that ships to the device, and evaluateLocal(policy, observation)
returns the findings to report with the next sync, and promptStepUp when a
finding maps to step-up or stronger, so the CLI can offer one. It never
refuses anything, and the server decides. The API side is described under
the API.

Escalation#

findingToEscalationEvent(finding, profile, ctx?) turns a finding into an
EscalationEvent for the API's EscalationManager:
source: 'klock', category: 'security', operation: <signal>, and a
locked level (escalation rules may notify on it but not re-grade it);
tags klock, profile:<name>, signal:<signal>, report-mode for a
report-mode profile, and the profile's own tags;
data: the profile, signal, severity, mode, the strongest action
(log in report mode), all actions, and sessionRef, deviceId, ip,
country, value and threshold.
The klock-behaviour escalation preset routes those events:
1.
Report-mode findings are recorded and notify nobody.
2.
agent.tamper, sync.rollback and impossible-travel are raised to
critical and go to security and on-call.
3.
A freeze or revoke action is raised to error and goes to security.
4.
Five findings for one user within an hour are raised to warning and go to
security.
5.
Other findings at warning or above go to security, rolled up every 15
minutes.
See escalation presets.
One request, end to end, with the payments profile above in enforce mode:

The CLI#

CommandDoes
prorm behaviour list [--scope …]Profiles on a scope. Alias prorm behaviour.
prorm behaviour presets [name]The presets, or one in full.
prorm behaviour show <id>One profile.
prorm behaviour apply <preset> [--scope …] [--mode report|enforce] [--name …]Applies a preset (report mode by default). Alias behaviour add.
prorm behaviour set --file profile.json [--scope …] [--mode …]Saves a profile from a file, validated first.
prorm behaviour rm <id>Removes a profile.
prorm behaviour test [preset] [--file …] [--mode …] [--country … --ip … --asn … --device … --key-store … --client … --kind … --tier … --caps … --at … --baseline file.json]Evaluates a profile locally against a described request: which signals fire, and what it would do.
prorm behaviour findings [--ack <id>] [--all]What the profiles noticed, newest first; --ack acknowledges one.
behavior is accepted for behaviour in every command. --scope is
account (the default), team:<t>, device:<id>, session:<id> or
service:<id>. A profile is validated and its extends chain resolved
before anything is sent. Reading needs behaviour:read; apply, set and
rm need behaviour:write; team profiles need behaviour:team.

Example: a profile for the payments engineers#

The payments team from the access-rule example
also wants to know when an engineer's session behaves oddly, without refusing
anything that the rules allow. This profile starts from standard-user and
tightens it:
{
  "name": "payments-engineers",
  "extends": "preset:standard-user",
  "applies": { "kinds": ["interactive", "device"] },
  "normal": {
    "locations": { "countries": ["GB", "IE"], "learn": true, "maxNewPerWeek": 1 },
    "hours": [{ "days": [1, 2, 3, 4, 5], "start": "07:00", "end": "20:00", "timezone": "Europe/London" }],
    "devices": { "requireKeychain": true, "maxNew": 2, "learn": true },
    "clients": ["cli", "browser", "mcp:name=Claude Code"],
    "operations": {
      "rates": [
        { "cap": "data:write", "max": 2000, "per": "1h" },
        { "cap": "resource:delete", "max": 2, "per": "1d" }
      ],
      "volumes": [{ "metric": "rowsRead", "max": 2000000, "per": "1d" }]
    }
  },
  "capabilities": { "deny": ["billing:manage"] },
  "on": [
    { "when": "location.outside", "do": ["alert", "freeze"] },
    { "when": "hours.outside", "do": ["alert"] },
    { "when": "device.file-key", "do": ["step-up"] },
    { "when": "rate.exceeded", "do": ["alert", "rate-limit"], "rateLimit": { "max": 20, "per": "1m", "for": "30m" } }
  ],
  "escalation": { "level": "warning", "preset": "klock-behaviour", "tags": ["payments"] }
}
PartWhat it says
extendsKeep standard-user's defaults (impossible-travel step-up, freeze on tamper or rollback) and override the rest. Lists replace the preset's; on entries merge by when.
applies.kindsPeople only. Give the team's CI service sessions the ci-service preset instead.
normal.locationsThe declared countries. Leaving them raises location.outside, which this profile turns into alert and freeze. A new country inside them is learned, at most one a week.
normal.hoursOutside office hours is worth an email (alert), not a refusal: on-call engineers work at night.
normal.devicesA key in a file rather than the OS keychain asks for a step-up. At most two new devices are learned; a third raises device.unknown.
normal.clientsClaude Code is expected. Any other MCP client raises client.unexpected, which alerts by default.
normal.operations2,000 writes an hour and two deletes a day are normal. Past that, rate-limit gives the session 20 requests a minute for 30 minutes. The hourly cron checks the 2,000,000-rows-a-day read volume.
capabilities.denyEngineers never touch billing. A session that uses billing:manage raises cap.outside-profile (logged). The team role still decides whether it is allowed.
escalationEvery finding is at least warning, tagged payments, and goes through the klock-behaviour escalation preset.

Test it locally#

prorm behaviour test validates the file and evaluates it against the
request you describe. A baseline file stands in for what the server has
learned so far ({"countries":["GB"],"devices":["dev_7QK2"]}):
$ prorm behaviour test --file payments-engineers.json --baseline baseline.json \
    --kind interactive --country GB --device dev_7QK2 --key-store keychain --client cli \
    --caps data:read --at 2026-09-28T10:00:00+01:00
✓ Normal: no signal fires.
$ prorm behaviour test --file payments-engineers.json --baseline baseline.json \
    --kind interactive --country PT --device dev_7QK2 --key-store keychain --client cli \
    --caps data:write --at 2026-10-03T23:30:00+01:00
! location.outside (high): outside the declared locations: country PT → alert, freeze
! location.new-country (medium): new country PT → alert
! hours.outside (medium): outside the declared hours → alert
Actions (enforce): alert, freeze
$ prorm behaviour test --file payments-engineers.json --baseline baseline.json \
    --kind interactive --country GB --device dev_7QK2 --key-store file \
    --client 'mcp:name=Claude Desktop' --caps billing:manage --at 2026-09-28T10:00:00+01:00
! device.file-key (low): the device key is held in a file → step-up
! client.unexpected (medium): unexpected client mcp:name=Claude Desktop → alert
! cap.outside-profile (medium): outside the profile: billing:manage → log
Actions (enforce): log, alert, step-up

Save it in report mode#

$ prorm behaviour set --file payments-engineers.json --scope team:payments
✓ Profile “payments-engineers” on team:7 (report).
Report mode: deviations are recorded as findings; nothing is refused.
For a week, every deviation is a finding with the action log, and
wouldApply records what enforce mode would have done.

Review the findings#

The team's findings are at GET /teams/:teamId/security/findings and on the
team page's Behaviour tab (behaviour:team). Widen the profile where the
findings are normal work: a country the team really travels to, or a higher
write rate.

Enforce it#

Saving upserts by scope and name, so this bumps version rather than adding
a second profile. Weakening an enforcing profile later needs a fresh step-up.
[!WARNING]
freeze stops the session until someone unfreezes it with a step-up from
another session
. An engineer on their only laptop, abroad, has to use the
console or another signed-in device. Keep freeze for signals you are sure
of, such as location.outside here, and use alert or step-up for the rest.

The API#

The API evaluates rules and profiles on every request. The middleware
(api/middleware/accessRules.js) is mounted before the routers. It works out
who is calling from the credential, verified the same way the routers verify
it: a bearer JWT and its session row, a klock grant and its session row, or
an API key's prefix. A request with no credential, or one that does not
verify, gets no rules here, and the route's own authentication refuses it
as before. The logic is api/controllers/accessRules.ts and
api/controllers/behaviour.ts. All paths are under the API's base path.

Access rules#

Method and pathBody or queryAnswer
GET /security/access-rules?scope=account, team:<id|uuid|slug>, database:<id|uuid|name> (or db:), store:<…> or device:<dev_…>. Without a scope: every scope you can see.{ rules }
GET /security/access-rules/context–{ ip, country, asn, deviceId, client }: this request as the rules see it
GET /security/access-rules/:id–{ rule }
POST /security/access-rules{ scopeKind, scopeId?, effect, dimension, value, appliesTo?, mode?, enforceNow?, expiresAt?, note?, mandatory?, force?, totp? | password? }201 { rule, forced? }
DELETE /security/access-rules/:id{ force?, totp? | password? }{ id, removed: true, rule }
A rule, as answered, is { id, uuid, scopeKind, scopeId, scope, effect, dimension, value, appliesTo, mandatory, mode, enforceAt?, note, expiresAt, enabled, createdAt, createdBy, lockdown?, legacy? }. mode is the mode
now. enforceAt is present while a new rule is in its report period.
Who may write which scope:
ScopeWho
accountyou, with access-rule:write
devicea device you have a session on
database, storethe owner, or a team role with access:write
teama team role with access-rule:team (owner, admin, security)
An unknown scope and a forbidden one both answer 404. A scope holds at most
200 rules. A value is at most 500 characters, and a note at most 200.
[!NOTE]
Every write (creating or removing a rule or profile, acknowledging a
finding) needs a verified email address. There is no route that edits a
rule: to change one, remove it and add the new one.
How a request is judged.
Scopes. The rules of every scope the request falls in are evaluated
together: the account, the device, the team contexts, and the database or
store in the path. A team context is a klock session opened for the
team, an x-prorm-team header, a /teams/:teamId/… path, or a request to
a team's resource (/provision/databases/:id, /provision/stores/:id).
Team rules apply only in that context, and only to active members.
appliesTo is matched against the capability the route needs, from the
capability table (see what each route needs).
A route that is not in the table needs none, so only rules without
appliesTo apply to it.
Denied. An enforce-mode deny answers 403 { error: "access_denied" },
and never says which rule matched. The audit row access.denied (sampled)
does. For a database it is also a klock.access-denied database event, so
database policies can react.
Report. A report-mode deny lets the request through and records
access.would-deny, sampled to one per user and rule set every 10 minutes.
Geography. Country and ASN come from Cloudflare, copied onto
x-prorm-geo-country and x-prorm-geo-asn by the Worker entry after any
client-set copies are removed. Off Workers those headers are ignored unless
TRUST_GEO_HEADERS=1, because they would be the client's own words. The
same applies to x-prorm-client-cert-sha256.
Clients. The client dimension sees cli (klock interactive and device
sessions, and legacy CLI tokens), mcp or mcp:name=<clientName>
(delegated tokens and forwarded MCP calls), api (API keys), agent
(service sessions) and browser. An MCP client's name is self-reported, so
a rule on it is advisory until MCP clients register through OAuth.
Caching. Rule and profile reads are reused per isolate for
ACCESS_RULES_CACHE_MS (30 000 by default). A write drops the cache in
its own isolate at once.
Admins. An admin's assumed identity is not subject to the customer's
rules; assumption has its own controls and audit.
The report period. A new rule reports for 24 hours, then enforces. The
hourly cron also writes the new mode to the row. mode: "report" keeps a
rule reporting indefinitely, and enforceNow: true (or mode: "enforce")
enforces it at once.
The save-time guard. A create or delete that would deny your own
current request
answers 409 would_lock_you_out with reasons (code,
dimension, scope, message) and ruleIds. It judges every rule as
enforced, for the routes that manage the account and its rules. force: true saves anyway only with a fresh step-up (or totp or password in the
body) and a verified email address, because email is the way back in.
Without a verified address the answer is still 409. A forced save is
audited at level alert.
The legacy block list. A database's database_access.blockedIps is
mirrored into database/block/ip rules, marked legacy: "blockedIps"
(note legacy:blockedIps), in report mode. They are visible and record
would-deny findings, but refuse nothing new: the column stays the authority
for API-key authentication and the data plane. The mirror is brought in line
after PUT /provision/databases/:id/access, when a database's rules are
listed, and by the hourly cron, which also makes the first copy of existing
values. The engine firewall, allowedIps, is a different layer and is not a
rule.

Behaviour profiles#

Method and pathBody or queryAnswer
GET /security/behaviour?scope=account, team:<t>, device:<id>, session:<jti|id> or service:<jti|id>{ profiles }
GET /security/behaviour/presets–{ presets: [{ name, description, spec }] }
GET /security/behaviour/findings?all=1 includes acknowledged ones{ findings }, newest first, at most 500
POST /security/behaviour/findings/:id/ack–{ finding, acknowledged: true }
GET /security/behaviour/:id–{ profile }
POST /security/behaviour{ scopeKind, scopeId?, name, preset?, spec?, mode?, totp? | password? }201 (new) or 200 (updated) { profile, created }
DELETE /security/behaviour/:id{ totp? | password? }{ id, removed: true }
A save upserts by scope and name and bumps version. spec may be left out
when preset is given (it becomes extends: "preset:<name>"). A new profile
is in report mode unless mode says otherwise. Weakening an enforcing
profile, by editing or removing it, needs a fresh step-up. Every change is
emailed to the account.
Where signals are computed.
WhereSignals
Per request, in the middlewarelocation.new-country, location.new-asn, location.outside, hours.outside, device.unknown, device.new, device.file-key, client.unexpected, cap.outside-profile, and rate.exceeded, counted in the DeviceGuard Durable Object per profile and session
The hourly cron (api/basic/controllers/behaviourCron.ts)volume.exceeded for rowsRead (the query log: your rows, and your databases' rows) and transferBytes (finished transfer jobs), one finding per profile, user and window. filesBytes has no source yet: klock file transfers do not feed the cron.
Profiles apply by scope: the account, the team contexts, the device, and the
session (session:<jti> or service:<jti>). Baselines of countries, ASNs and
devices are learned from normal requests; learn: false, maxNewPerWeek and
maxNew stop them widening. The klock service evaluates the profiles that
apply to a device at each sync and raises impossible-travel,
agent.tamper, sync.stale and sync.rollback there, from what the device
reports (klock.tamper-detected, klock.sync-rollback) and what only klock
sees. Its findings are recorded as behaviour_findings rows with
data.source: "klock". alert emails are not sent for klock findings yet;
they are recorded and escalated.
What the actions do. In enforce mode only; a report-mode profile records
log.
ActionEffect
logAn audit row.
alertThe account is emailed, at most once per profile and signal an hour.
step-upThe session's step-up is cleared, so its next gated call needs one.
rate-limitThe session gets a budget (rateLimit { max, per, for }, 10 a minute for 15 minutes by default); past it, 429 rate_limited with Retry-After.
freezeThe session answers 423 session_frozen until it is unfrozen, with a step-up, from another session.
revokeThe session is revoked: 401 session_revoked.
A request made with an API key has no session, so it produces findings only.
Findings are behaviour_findings rows, immutable except for
acknowledgement. Each is also a behaviour.deviation audit row and an
escalation event, built by findingToEscalationEvent and stored on the
finding as data.escalation, and it is handed to the escalation listeners
registered in the API. On a database request it is also a klock.deviation
database event. klock.deviation and klock.access-denied are in the policy
catalogue, so database policies can react to both.

Team security#

Under /teams/:teamId/security:
RouteNeeds
GET /access-rulesany active member
POST /access-rules, DELETE /access-rules/:ruleIdaccess-rule:team. A new team rule is mandatory unless you say otherwise.
GET /behaviourany active member
POST /behaviour, DELETE /behaviour/:profileIdbehaviour:team
GET /findings[?all=1], POST /findings/:findingId/ackbehaviour:team
POST /lockdown/:memberId, DELETE /lockdown/:memberIdsession:manage-members, with { reason }
:memberId is the team-member row id, as in /teams/:teamId/members/:memberId.
The team owner skips the team's own rules on these /teams/:id/security/*
routes, and only there, so a bad team rule cannot lock the owner out of
fixing it.
Locking down a member suspends their membership (the team's resources refuse
them) and revokes their sessions opened for the team. It is audited at
alert with the actor's id, and the member is emailed. Only the owner may
lock down an admin, and the owner cannot be locked down. DELETE reverses
it.

Recovery#

A person locked out by their own rules gets back in by email. Both routes are
public.
RouteBodyAnswer
POST /security/recovery/start{ email }Always 202 { message }, for an unknown address too. At most 10 an hour per client address (429 rate_limited), and 3 links an hour per account (silently).
POST /security/recovery/complete{ code, password, totp? | recoveryCode? }{ token, tokenType, recovery: true, expiresIn: 3600, expiresAt, disabledRules, message, user }
The email carries a link to the console, #/recover?code=…, valid 30 minutes
and usable once. Failed attempts count against the same lock-out as sign-in.
A successful recovery gives a one-hour browser session that:
skips account-scope rules only: team, device and resource rules still
apply;
switches the account's block rules to report mode, with the note
recovery: pending review, so you can fix them;
ends a lockdown in force.
It is audited at alert (access-rule.recovery) and the account is emailed.

Lockdown#

/security/lockdown is the emergency stop for an account you think is
compromised.
RouteBodyAnswer
POST /security/lockdown{ includeServices?, totp? | password? }{ lockedDown: true, revoked, suspended, devices, lockdownUntil }
DELETE /security/lockdown{ totp? | password? }{ lockedDown: false, released }
GET /security/lockdown–{ lockedDown, lockdownUntil }
Engaging needs a step-up: a TOTP code when the account has a second
factor, or the password from a browser without one. A klock CLI session on an
account without a second factor needs nothing more, because its device-signed
request is the proof. It then:
1.
revokes every browser, CLI, interactive, device, delegated, handoff and
assumed session, the caller's included, and service sessions only with
includeServices: true;
2.
asks the klock service to suspend every device (best effort);
3.
for 15 minutes, refuses every request of the account with
423 { error: "lockdown", lockdownUntil }, including sessions signed in
after the lockdown, except a recovery session;
4.
audits at alert and emails the account.
Releasing needs a session opened within the last 15 minutes plus the
second factor now (or the password, from a browser without one), or a
recovery session. Otherwise it answers 401 fresh_signin_required or
step_up_required. Devices stay suspended: reinstate them one by one with
prorm devices reinstate <id>.

Error codes#

access_denied (403), lockdown (423), would_lock_you_out (409),
invalid_rule and invalid_profile (400), rate_limited (429),
recovery_failed (400 or 401) and fresh_signin_required (401). The routes
also answer step_up_required, totp_required, session_frozen,
session_revoked, capability_missing and too_many_attempts. Each has an
entry in the klock error reference.

On the klock service#

The klock service fetches a device's rules and profiles from the API over RPC
(ApiInternal.compiledRules): the owner's account rules, the rules of teams
they are an active member of, and the device's own. It evaluates them with the
same engine at three points:
Sync. A denied device still syncs (status, revocations and commands) but
gets no grant: grantError: "access_denied", and only remove credential
updates. The API records an access.denied audit row with
detail.layer: "klock".
Enrolment (POST /v2/enrol). A deny is 403 access_denied, before
anything is written, and the ticket is not spent.
Credential issue (/v2/credentials/*). A deny is 403 access_denied;
here capability-scoped rules (appliesTo) apply too, to the capabilities
the credential stands for.
Report-mode rules never refuse; a would-deny is recorded as
access.would-deny. If the rules cannot be read, klock fails closed: a sync
gets grantError: "policy_unavailable" and no grant, and enrolment and
credential issue answer 503 policy_unavailable. A rule change takes up to
ACCESS_RULES_CACHE_MS (30 seconds) plus the device's next sync to reach a
device. The API still checks every request those devices make.

In the console#

The console has the same controls: account rules, profiles and findings on
the Security page, team rules and profiles on the team page, and an Access
rules tab on each database. It also has the recovery page. See
Security in the console.

Related reading#

klock: the .klock file, grants, sessions and devices
Capabilities: tiers, capability ids, team roles and enforcement
CLI: every prorm command
Audit levels and escalation: levels, presets and notifiers
Modified at 2026-10-04 00:40:16
Previous
YugabyteDB Dialect
Next
ArangoDB (AQL)
Built with