← All articles
Security & Governance Developers · · By ObjectStack Team

How Do You Catch an AI's Silent Permission Change Before It Ships? The Access-Matrix Snapshot Gate, on a 7-Entry CRM

On @objectstack/cli 18.2.0, os validate passes an agent's allowDelete false-to-true flip with no finding. os compile on an opted-in app fails the build with one line naming who gains what, until a human regenerates the snapshot.

How Do You Catch an AI's Silent Permission Change Before It Ships? The Access-Matrix Snapshot Gate, on a 7-Entry CRM How Do You Catch an AI's Silent Permission Change Before It Ships? The Access-Matrix Snapshot Gate, on a 7-Entry CRM
  • AI agents
  • Agent permissions
  • Reviewable diff

Answer first. os validate does not read the value of allowDelete, so an agent that flips it from false to true on a permission set passes validation with no finding. That is what the review checklist means by “no rule fires”, and it stays true. There is one gate that does fire, if the app opts in: a committed access matrix, one row per (permission set × object) carrying the effective create, read, edit and delete bits, which os compile re-derives from the metadata on every build and diffs against the snapshot. Drift fails the build with a semantic line, 'crm_sales_user' gains delete on 'crm_opportunity', until a human regenerates the snapshot, and the snapshot’s git diff is what the reviewer signs. Measured on the published @objectstack/cli 18.2.0, the version npm create objectstack@latest installs, against the bundled CRM example whose matrix has seven entries: one edit, one drift line, one changed line of JSON.

The concept worth keeping from this post is effective capability, not raw flags: the matrix reports what a permission set can actually do once the super-user bits are folded in, with the object’s record baseline on the same row. The change still needs a human. It can no longer be silent.

The change, and the two commands

The target is examples/app-crm from the ObjectStack repository at the tag @objectstack/cli@18.2.0, installed against the published 18.2.0 packages rather than the monorepo. It is opted in: an access-matrix.json sits next to objectstack.config.ts. The edit is the one the review post worked through, and it is one word on line 50 of src/security/sales-positions.ts:

  export const SalesUserPermissionSet = definePermissionSet({
    name: 'crm_sales_user',
    label: 'CRM Sales User',
    objects: {
      crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
      crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
-     crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
+     crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },

First door, npx os validate. The output is trimmed to the lines that matter; nothing between them names allowDelete, crm_sales_user or a matrix.

$ npx os --version
@objectstack/cli/18.2.0 linux-x64 node-v22.22.0

$ npx os validate
◆ Validate
  → Loading configuration...
  → Validating against ObjectStack Protocol...
  → Running author-time rules (53)...
  → Checking that every required capability has a provider installable in this edition...
  → Checking package docs (ADR-0046)...

  ✓ Validation passed (824ms)
  Security: 3 Positions  2 Permissions
$ echo $?
0

Fifty-three author-time rules ran. None of them has an opinion about whether sales representatives should delete opportunities, because that is a fact about your company and not about the metadata. The review post’s sentence is exactly right: both values are valid, both are enforceable, the runtime will enforce whichever one you merge.

Second door, npx os compile. Same configuration, same 53 rules, and then one more step that validate does not have:

$ npx os compile
◆ Compile
  → Loading configuration...
  → Normalizing stack definition...
  → Lowering inline handlers...
  → Validating protocol compliance...
  → Running author-time rules (53)...
  → Checking that every required capability has a provider installable in this edition...
  → Checking access-matrix snapshot (ADR-0090 D6)...

  ✗ Access matrix drift (1 change) — capability changes must be reviewed
  • 'crm_sales_user' gains delete on 'crm_opportunity'
  If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.
$ echo $?
1

os build is the same command under another name (in the 18.2.0 CLI source, Build extends Compile), and it fails with the same line. With --json, the failure is a payload a CI step can read:

{ "success": false, "error": "access matrix drift",
  "changes": ["'crm_sales_user' gains delete on 'crm_opportunity'"],
  "warnings": [ … ], "conversions": [] }

Two commands, one gate: os validate runs the 53 author-time rules and passes, because no rule reads the value of allowDelete; os compile runs the same rules, then derives the access matrix from the metadata, diffs it against the committed access-matrix.json, and fails the build with a semantic line until a human regenerates the snapshot and commits its diff

That is the whole mechanism, and the rest of this post is about what the line means, why it says delete when the source said allowDelete, and what the gate does not do.

What the gate is

The ObjectStack docs describe it in one paragraph, worth quoting rather than paraphrasing:

AI drafts permission metadata on this platform; the one thing it must never do is silently change who can do what. The access-matrix gate makes every capability change a visible, reviewable build event: the (permission set × object) matrix is derived purely from metadata on every os compile, diffed against a committed snapshot, and any drift fails the build with a semantic explanation until a human regenerates the snapshot.

Three facts about it, each checked on 18.2.0:

  • It is per-app and opt-in. Commit an access-matrix.json next to objectstack.config.ts; generate the first one with os compile --update-access-matrix. No file, no gate: the CLI reads the path once and treats a missing file as “not opted in”. The two bundled examples, app-crm and app-showcase, ship opted in. A fresh npm create objectstack@latest scaffold does not: the 18.2.0 template contains no access-matrix.json, so the first command of the opt-in is yours to run.
  • It is not one of the 53 rules. The CLI source files it under “not a registry rule: it reads (and with the flag, writes) a file next to the config rather than answering a question about the stack.” That is why validate and lint never see it: they run the registry, and the snapshot gate lives in compile.
  • It is small. The CRM’s whole committed matrix is seven entries, and seven rows is every grant the app declares: six objects for crm_sales_user and one insert-only row for the anonymous guest_portal profile behind the public web-to-lead form.
Permission setObjectcreatereadeditdeleteView AllModify AllRecord baseline (OWD)
crm_sales_usercrm_account✓✓✓–––public_read_write
crm_sales_usercrm_activity✓✓✓–––public_read_write
crm_sales_usercrm_contact✓✓✓–––public_read_write
crm_sales_usercrm_lead✓✓✓–––public_read_write
crm_sales_usercrm_opportunity✓✓✓–––public_read_write
crm_sales_usercrm_opportunity_line_item✓✓✓–––controlled_by_parent
guest_portalcrm_lead✓–––––public_read_write

A reviewer who has never opened the CRM’s source can read that table in under a minute and say what the app lets a sales user do. That is the point of keeping it as a committed file: the capability model of the application is a seven-row table in the repository, and every pull request that changes the table has to say so in its diff.

On disk, each row is one JSON object. The crm_opportunity row, before the edit:

{
  "permissionSet": "crm_sales_user",
  "object": "crm_opportunity",
  "create": true,
  "read": true,
  "edit": true,
  "delete": false,
  "viewAllRecords": false,
  "modifyAllRecords": false,
  "sharingModel": "public_read_write"
}

The drift lines are generated from a diff of two such tables, and there are exactly five shapes of line. They are the vocabulary a reviewer is going to read, so here they are, from the 18.2.0 source of diffAccessMatrix:

What changedThe line
A row disappeared'SET' loses ALL access to 'OBJECT' (entry removed)
A row appeared'SET' gains access to 'OBJECT' (create, read, …)
One bit flipped on an existing row'SET' gains delete on 'OBJECT' or 'SET' loses read on 'OBJECT'; View All and Modify All use the same form
The read or write depth moved'SET' read depth on 'OBJECT': own → org
The object’s record baseline moved'OBJECT' record baseline (OWD): private → public_read_write (affects every principal)

Our edit is the third shape. The line says gains delete rather than quoting the key, which is the subject of the next section.

Effective capability, not raw flags

Look again at the row above. It has a delete column, not an allowDelete column, and the difference is deliberate: the matrix reports effective capability. In the 18.2.0 spec, one function decides whether an object permission grants a verb, and it is the same function the runtime’s enforcement door asks before it answers 403. Its fold, from objectPermissionGrants in @objectstack/spec:

  • read is true when allowRead is true, or viewAllRecords is true, or modifyAllRecords is true.
  • edit, delete and transfer are true when their own bit is true or modifyAllRecords is true.
  • create has no super-user bypass: Modify All widens edit and delete, it does not manufacture a create grant.
  • export is a conjunction, allowExport and read, so a set that grants export on an object it cannot read grants nothing. (The matrix carries only the four CRUD columns; transfer and export are the fold’s other verbs, listed here because the fold is the point.)

The spec’s own comment on why it is not permission[target] === true is the declared-versus-enforced gap “pointed the dangerous way round: a predicate hiding an action from the one administrator who holds the power to use it.” A matrix built from the raw allow* bits would have the same blind spot. So the matrix asks the fold, and the two super-user columns are kept as raw bits beside it, because they are the context a reviewer reads the CRUD columns against.

Effective capability, not raw flags: the six raw bits on a permission set pass through the spec's fold, objectPermissionGrants, where View All and Modify All widen read, edit and delete and create has no bypass, and land as one matrix row whose create, read, edit and delete are effective, whose View All and Modify All columns are the raw bits kept as context, and which carries the object's record baseline; measured on 18.2.0, replacing allowDelete false with modifyAllRecords true on the row, one source line that names no delete, produces two drift lines, gains delete and gains Modify All Data

The measurable consequence: a different one-line edit on the same row, replacing allowDelete: false with modifyAllRecords: true, produces two lines rather than one, and the first of them is the delete nobody typed:

$ npx os compile
  ✗ Access matrix drift (2 changes) — capability changes must be reviewed
  • 'crm_sales_user' gains delete on 'crm_opportunity'
  • 'crm_sales_user' gains Modify All Data on 'crm_opportunity'

npx os validate on that edit: ✓ Validation passed (732ms). The source line no longer says anything about delete; the matrix says true, because the runtime would let the delete through, and the matrix’s job is to tell the reviewer what the 403 would say, not what the source file says.

One pair the spec no longer accepts, and it is the obvious edit to try: leaving allowDelete: false in place and adding modifyAllRecords: true beside it. Before 18.2.0 that pair loaded, and the matrix was the only door that reported the delete it granted. Since 18.2.0 the spec refuses it before any rule or gate runs, validate exit 1 and compile exit 2 with the same message:

$ npx os validate
  ✗ [ { "code": "custom", "path": ["objects", "crm_opportunity", "allowDelete"],
        "message": "allowDelete: false is refused beside modifyAllRecords: true since @objectstack/spec 18 (ADR-0049): Modify All Records is the write bypass for edit, delete and transfer, and the permission evaluator grants delete on the bypass bit before it reads allowDelete, so this false is never enforced. …" } ]
$ echo $?
1

That is the fold above, enforced one door earlier: a false the runtime would ignore is now a schema error rather than a silent lie, and what the accepted spelling means is what the matrix row says.

The last column matters for the same reason. The review post’s second pass told you to read an object’s sharingModel even when the diff does not touch it, because public_read_write on a copied demo object is org-wide read and write with every gate green. Here the baseline sits on the row next to the grant, and a baseline change is its own drift line, marked affects every principal, because an OWD swing changes what every row for that object means at once.

Regenerate, then read the diff

The failure message says what to do when the change is intended, and the loop is three commands.

$ npx os compile --update-access-matrix
  → Checking that every required capability has a provider installable in this edition...
  → Access matrix snapshot written to access-matrix.json (ADR-0090 D6) — review the diff.
  → Writing artifact...

  ✓ Build complete (1121ms)

$ git diff -U8 access-matrix.json
@@ -46,17 +46,17 @@
       "sharingModel": "public_read_write"
     },
     {
       "permissionSet": "crm_sales_user",
       "object": "crm_opportunity",
       "create": true,
       "read": true,
       "edit": true,
-      "delete": false,
+      "delete": true,
       "viewAllRecords": false,
       "modifyAllRecords": false,
       "sharingModel": "public_read_write"
     },

$ npx os compile
  → Checking access-matrix snapshot (ADR-0090 D6)...
  ✓ Build complete (702ms)

Two practical notes from running it. The flag rewrites the whole file unconditionally, so after a regeneration compare the number of hunks in access-matrix.json with the number of semantic lines the failing compile printed: one line, one hunk, and anything extra is a change you did not ask for. And read the hunk with -U8 rather than the default three lines of context. The row’s permissionSet and object keys sit five and six lines above the changed bit, so a default git diff shows you "delete": false → true with the row’s name cut off, and the semantic line from the compile is then the only thing telling you whose delete it was.

The pull request now carries two hunks that say the same thing in two vocabularies: the source flip an agent wrote, and the capability row a human regenerated. The docs’ phrase for the second one is the right one, and it is the sentence this post exists to measure: “its diff IS the review artifact.”

Where it sits among the other defenses

The gate is one of three mechanisms built on the same structured grants, and each catches something the others cannot.

MechanismWhat it catchesWhen it runsWhat it does
Security posture linter (ADR-0090 D7)an individually dangerous declaration: an unset OWD, a high-privilege anchor suggestion, a retired aliasvalidate, build, lint, as registry rulesfails the build with the rule’s own message
Access-matrix snapshot gate (ADR-0090 D6)a legitimate-looking change whose blast radius a human should still sign offcompile and build, when the app has opted infails the build with semantic lines until the snapshot is regenerated
Explain enginewhy one principal’s one request resolved the way it didat runtime, per decisionanswers the question, enforces nothing new

The docs put the relationship between the last two in one sentence: the matrix “is the same evaluation run over representative (permission set × object) pairs; explain is the per-decision zoom lens.” And the reason all three exist at all is the same: grants are structured data with a closed vocabulary, so a build step can derive a table from them. A permission model written as freeform predicates in application code has no table to diff.

What the gate does not do

Five limits, stated plainly, because the sentence “the build catches permission changes” is the kind that gets repeated without its conditions.

  1. It is opt-in, and the default is off. A fresh 18.2.0 scaffold has no snapshot, so it has no gate. The opt-in is one command and one committed file, and the pull request that adds the file is the one to review most carefully: that snapshot is the baseline every later diff is read against.
  2. It runs in compile and build, never in validate or lint. Measured above: the same edit, validate exit 0 with no mention of the matrix, compile exit 1. A CI pipeline that runs os validate and calls the app checked has not run this gate.
  3. It makes the change visible, not impossible. --update-access-matrix is one flag, and an agent can run it and commit the new snapshot itself. What the agent cannot do is make the capability change disappear from the pull request: the row is in the diff whether a human or a model regenerated it. The third rung of the review ladder, is this right, is still the reviewer’s, and the gate’s contribution is that the question now arrives with the answer’s shape on one line.
  4. Its scope is (permission set × object). Create, read, edit, delete, the two super-user bits, read and write depth, and the object’s baseline. Not field-level security, not sharing rules, not row-level predicates, not which users hold the set (that is runtime data, as the review post’s limits already say), and not what an action body does once it runs.
  5. For a one-word flip, the source diff already shows it. This is the fair objection: allowDelete: false → true is visible in sales-positions.ts to anyone who reads the diff. The matrix earns its keep where the source change and the capability change have different shapes. One modifyAllRecords: true is one source line and two capability lines. One sharingModel edit on an object file is one source line and a line on every set’s row for that object. A permission model assembled across several files still lands as one sorted table that a reviewer can read without opening any of them. The gate’s value is not that it sees the flip. It is that it sees the flip in the vocabulary the runtime enforces.

The two lines to add to the checklist

The review post ended with a checklist. The gate adds a row to its second pass and a question to its first.

[ ] Which door? If the pipeline ran only os validate, the access-matrix gate did not run.
[ ] Pass 2 — permissions:
    [ ] access-matrix.json is committed (opt in once: npx os compile --update-access-matrix)
    [ ] on drift: one semantic line per hunk in access-matrix.json, and the PR says why
    [ ] read each hunk with -U8, so the permissionSet and object are in it
    [ ] for every "gains" line, say who gains what, and read the OWD on the same row

Point your agent at a target whose grants are a table

An agent can generate a permission model in any stack. What it cannot do in most of them is hand you a seven-row table that the build re-derives and compares on every run, because in most stacks the permission model is spread across middleware, query filters and a template or two, and there is no table to compare. The gate above exists because grants in ObjectStack are typed metadata with a closed vocabulary, which is the same property that makes the diff reviewable and keeps a declared permission from being only a comment. If you want the argument for where the enforcement itself runs, it is in where AI agent permissions are enforced.

So point your agent at that target, with a rules file or the spec and MCP surface directly, and make the snapshot the first thing you commit:

npm create objectstack@latest my-app && cd my-app
# open it in your coding agent and describe what the business needs
npx os compile --update-access-matrix   # the first snapshot: review it, commit it
npx os compile                          # in CI, every run; os validate alone does not run the gate

From then on, an agent is free to draft grants, and the one thing it cannot do is change who can do what without a line in the build output and a row in the diff.

To run it, the ObjectStack repository ships ObjectOS Community — the ghcr.io/objectstack-ai/objectstack image, os start and the Console, free and self-hosted — while ObjectOS Cloud is the hosted edition and ObjectOS Enterprise the self-managed, licensed one.