Skip to content

feat: persist permissions in postgres, default messaging to ALWAYS, hide from UI#956

Open
njbrake wants to merge 9 commits intomainfrom
feat/permissions-to-db-and-messaging-always
Open

feat: persist permissions in postgres, default messaging to ALWAYS, hide from UI#956
njbrake wants to merge 9 commits intomainfrom
feat/permissions-to-db-and-messaging-always

Conversation

@njbrake
Copy link
Copy Markdown
Collaborator

@njbrake njbrake commented Apr 14, 2026

Description

Three connected fixes to the permissions system. User reported: "I redeployed on Railway and now the QuickBooks special overrides were gone as were some other permissions updates I made."

1. PERMISSIONS.json now lives in postgres

`ApprovalStore` used to write `data/users/<user_id>/PERMISSIONS.json` to the local filesystem. On Railway — and in local docker-compose — that path isn't on a persistent volume, so every redeploy wiped every override the user had granted.

  • New `user_permissions` table (one row per user, JSON blob in `TEXT`).
  • Alembic migration `015` creates it and best-effort backfills any surviving on-disk files.
  • `ApprovalStore._load` / `_save` read and write the row directly.
  • `workspace_tools` (`read_file` / `write_file` / `edit_file`) also routes `PERMISSIONS.json` through that same row (new `_is_permissions_path` / `_permissions_read` / `_permissions_write` — same dispatch shape as the existing `USER.md` / `MEMORY.md` branches).

So the typed `set_permission` API and the agent's `read_file("PERMISSIONS.json")` share one source of truth. No more drift.

2. Dropped `ToolName.UPDATE_PERMISSION`

Previously, ALWAYS_ALLOW / ALWAYS_DENY emitted a synthetic `update_permission` tool record so the chat would show "Saved: X will always run". Separate vocabulary for the same concept — the file changed.

Now `core.py._record_permissions_edit` emits a synthetic `write_file(path="PERMISSIONS.json", content=)` record instead. One verb for "the file changed", whether the agent did it via `edit_file`, the user edited it on the dashboard, or the approval gate persisted an Always.

3. Messaging tools default to ALWAYS and are hidden from the UI

Normal chat replies return as the agent's final text string — they don't go through `send_reply`. That tool is only called for proactive / cross-channel sends (heartbeat, different-channel delivery). ASK was a safety valve for a narrow path the user shouldn't have to babysit on the Permissions page.

  • `send_reply` / `send_media_reply` `ApprovalPolicy.default_level = ALWAYS`.
  • `SubToolInfo(default_permission="always", hidden_in_permissions=True)`.
  • New `hidden_in_permissions: bool` field on `SubToolInfo`, propagated through `SubToolEntry` -> `SubToolEntryResponse` -> frontend.
  • `PermissionsPage.tsx` filters out hidden sub-tools and drops any tool card left with no visible sub-tools.

Type

  • Feature

Tests

  • `test_always_allow_emits_write_file_permissions_record` and `test_always_deny_emits_write_file_permissions_record` — replace the old `update_permission` assertions; check that ALWAYS_ALLOW / ALWAYS_DENY land as `write_file` records on `PERMISSIONS.json` with the new snapshot in `content`.
  • `test_permissions_json_shared_between_approval_store_and_workspace` — seeds the store with `qb_query + Invoice = always`, reads `PERMISSIONS.json` via workspace, edits the content, and asserts `ApprovalStore.check_permission` reflects the change.
  • `test_tool_policies.py` / `test_tool_config.py` — updated assertions: messaging tools default to ALWAYS, hidden_in_permissions is True.

Local pytest was hanging in the sandbox, so I stopped re-running it and am letting CI do the final check per your call.

Checklist

  • Lint + format + type check clean (backend + frontend)
  • Frontend `tsc --noEmit` + `knip` clean
  • `openapi.json` + `api.d.ts` regenerated
  • Tests — to be verified by CI

AI Usage

  • AI-assisted — Claude Opus 4.6 (1M context)

🤖 Generated with Claude Code

… from UI

Three connected fixes to the permissions system.

1) PERMISSIONS.json is now a DB-backed workspace document.
   ApprovalStore used to write data/users/<user_id>/PERMISSIONS.json to
   the local filesystem, so every Railway redeploy or docker-compose
   restart wiped every override a user had granted ("Always" decisions,
   per-resource overrides, the lot). New user_permissions table (one
   row per user, JSON blob in a TEXT column) backs the store; alembic
   migration 015 creates it and best-effort backfills any surviving
   files on disk.

   workspace_tools (read_file / write_file / edit_file) also routes
   PERMISSIONS.json through the same DB row, so the chat's
   "read_file PERMISSIONS.json" and the approval system share a single
   source of truth. No more drift between what ApprovalStore thinks is
   saved and what the agent reports when the user asks.

2) Dropped ToolName.UPDATE_PERMISSION. When ALWAYS_ALLOW / ALWAYS_DENY
   fires, core.py now emits a synthetic write_file(path="PERMISSIONS.json",
   content=<new snapshot>) record instead of a bespoke "update_permission"
   tool. One vocabulary for "the file changed", whether the agent did
   it via edit_file, the user edited it on the Permissions page, or the
   approval gate persisted an Always.

3) send_reply / send_media_reply now default to ALWAYS and are hidden
   from the dashboard Permissions page. Normal conversational replies
   don't go through send_reply (they return as the agent's final text),
   so the ASK default was a safety valve for a narrow path the user
   shouldn't have to babysit. New SubToolInfo.hidden_in_permissions
   flag flows through SubToolEntry / SubToolEntryResponse to the
   frontend, which filters hidden sub-tools and drops any card left
   with none visible.

Tests cover: ApprovalStore round-tripping through the DB, write_file
on PERMISSIONS.json flowing back into the store, write_file synthetic
records on ALWAYS_ALLOW and ALWAYS_DENY (replacing the old
update_permission assertions), messaging defaults now ALWAYS +
hidden_in_permissions=True.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added the missing-template PR is missing required template checklist label Apr 14, 2026
@github-actions
Copy link
Copy Markdown

PR Template Missing

This PR appears to be missing the required checklist from the PR template.

Please edit your PR description to include the checklist section.
The checklist helps maintainers review your contribution.

This PR will be automatically closed in 24 hours if the template is not restored.

If you are using an AI coding tool, please ensure it preserves the PR template.

njbrake and others added 8 commits April 14, 2026 17:27
… Always

Two follow-ups on the in-review PR:

1. ApprovalStore._save and _permissions_write_sync wrote to DB rows but
   never called db.commit(). db_session() context does rollback on error
   but otherwise leaves it to the caller -- so every "Always" in chat
   rolled back on session close and vanished. Adds the commit() the
   rest of the codebase already uses with db_session().

2. Switched the synthetic permissions record from write_file (single
   'content' arg, wholesale overwrite semantic) to edit_file with a
   full-file old_text / new_text pair. edit_file is the semantically
   right verb for "this file changed", and old/new lets the UI show a
   diff instead of opaque replacement. The snapshot is taken before
   set_permission so the record shows what was there plus what it
   became.

Tests updated to assert the edit_file record shape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ctions

PR #956's synthetic edit_file record -- combined with instructions.md
telling the agent "To change a permission: edit_file(PERMISSIONS.json,
old_text, new_text)" -- had the agent duplicate what the approval gate
already does. User says "always" to a prompt, the gate persists the
resources override, then the agent reads the system's synthetic
edit_file record in history, thinks it needs to do the edit itself,
and runs its own edit_file / write_file chain that overwrites the
resources block with a minified tool-level-only version. Next turn the
permission is effectively "ask" again and the gate re-prompts.

Four fixes, narrower than what I had:

1. Drop the synthetic edit_file / write_file / update_permission record
   entirely. ApprovalStore persists to the DB silently; visible feedback
   is the absence of future prompts plus the Permissions dashboard.
2. Rewrite the "## Permissions" section of instructions.md. Say
   explicitly that the system saves Always/Never automatically and the
   agent must NOT follow up with edit_file on PERMISSIONS.json. The
   agent only touches PERMISSIONS.json when the user asks in plain chat
   ("what are my permissions?" -> read_file; "set qb_query to ask" ->
   edit_file).
3. Normalize JSON in workspace_tools._permissions_write_sync: parse +
   re-serialize with indent so every reader sees the same shape
   regardless of what the caller passed in. Breaks the cascade where
   one minified write invalidated every later edit_file's old_text.
4. Tests: drop the synthetic-record assertions, add a negative test
   that ALWAYS_ALLOW does NOT produce any PERMISSIONS.json tool record,
   add a normalization test for write_file with minified content.

Kept from the earlier PR: DB-backed permissions, workspace dispatch of
PERMISSIONS.json to the same DB row, messaging-tools-to-ALWAYS + hidden
from UI, per-turn approval cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI failed with ForeignKeyViolation on every test that exercised
ApprovalStore directly: tests use ad-hoc string user ids ('1',
'backfill-user', etc.) to keep the approval store tests lightweight.
The previous FK to users.id rejected those inserts.

Drop the FK plus the ORM relationship. The store is fine with a plain
String primary key:
- User deletion in the premium layer is soft-delete (is_active=False),
  never a hard row delete, so cascade cleanup would never have fired
  anyway.
- If orphan hygiene is ever needed (it isn't today), handle it at the
  call site explicitly.

Migration 015 updated to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reported the double-approval prompt was still happening: the LLM
ignored the "don't edit PERMISSIONS.json after an approval" instruction
and kept calling write_file, wiping the resources section and
re-triggering the approval gate. Stop relying on the LLM to follow the
rule -- make writes structurally impossible.

- workspace_tools write_file / edit_file on PERMISSIONS.json return a
  PERMISSION error with a message explaining that the approval system
  and dashboard manage the file.
- Dropped the unused _permissions_write / _permissions_write_sync
  helpers. ApprovalStore still writes the DB row directly through its
  own path (and the dashboard's PUT /api/user/permissions endpoint
  still writes through ApprovalStore._save).
- instructions.md updated: PERMISSIONS.json is read-only for the agent.
  If the user asks to change a permission, redirect them to reply
  "Always" on the next prompt or to use the dashboard.
- test_permission_tools.py: test_write_permissions_json and
  test_edit_permissions_json flipped to assert the read-only rejection;
  test_read_permissions_json unchanged.
- test_media_staging.py: replaced the old "writes flow back into
  ApprovalStore" and minified-normalization tests with read-only
  regression tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI lints alembic/ too; my local run had been lint-checking only
backend/ and tests/. Ruff's I001 auto-fix sorted the imports.
- alembic/015 docstring notes the relative-CWD quirk of settings.data_dir
  and the Postgres-specific ON CONFLICT dependency.
- workspace_tools._is_permissions_path now case-insensitive (filename
  compared via .casefold()) so a lowercase permissions.json can't fall
  through to the disk write path.
- ApprovalStore._save drops the redundant explicit updated_at writes;
  ORM default/onupdate already handle it. Unused datetime import gone.
- Add test_always_deny_does_not_emit_synthetic_tool_record as the
  symmetric twin of the ALWAYS_ALLOW test.
- Add test_permissions_path_match_is_case_insensitive covering the
  path-match casefold behavior.

Per-test transaction rollback in conftest._isolate_stores already
cleans user_permissions rows, so no fixture change needed for that.
The original LLM misbehavior (agent rewriting PERMISSIONS.json after
Always replies) was stale-conversation bias, not a real adherence
failure. Old chat history showed the agent editing the file from
before instructions.md got the "don't do that" guidance, and the LLM
pattern-matched its own past behavior.

Clearing the session fixes it; locking writes is too aggressive and
breaks the legitimate uses (user says "set qb_query to ask" in plain
chat, or the dashboard writes through ApprovalStore._save which was
always independent of this path).

Restore:
- workspace_tools write_file / edit_file on PERMISSIONS.json route
  through the UserPermissionSet DB row (ApprovalStore and workspace
  tools share the same backing).
- _permissions_write / _permissions_write_sync with JSON normalization
  so minified agent writes can't break later edit_file old_text
  matching.
- Tests: test_write_permissions_json / test_edit_permissions_json
  assert the happy path; test_permissions_json_write_flows_into_
  approval_store verifies the typed store sees edit_file updates;
  case-insensitive path match test now asserts dispatch (not
  rejection).

Instructions.md keeps the "don't double-manage after an approval
reply" guidance but no longer claims the file is read-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes the lost-update race on UserPermissionSet:
- Approval gate persists an Always via ApprovalStore.set_permission
- Dashboard PUT /api/user/permissions hits ApprovalStore._save
- Agent edit_file or write_file on PERMISSIONS.json hits workspace
  tools' _permissions_write / _permissions_edit
All three read-modify-write in their own transactions with no row
lock, so interleaved writers can overwrite each other.

Fix: every writer acquires a pg_advisory_xact_lock keyed on
"user_permissions:{user_id}" before touching the row. Lock is
transaction-scoped (auto-released on COMMIT/ROLLBACK) and serializes
concurrent writers across workers.

- approval.py: new _lock_user_permissions helper + _parse_row_data
  dedup. set_permission now runs the whole read-modify-write in one
  locked transaction instead of ensure_complete() + _save() across
  two.
- workspace_tools: _permissions_write_sync acquires the same lock.
  New _permissions_edit_sync replaces the old read-then-write
  sequence with a single locked transaction that handles
  text_not_found / ambiguous / ok atomically.
- edit_file on PERMISSIONS.json routes through _permissions_edit.

Postgres-only. Project's tests and production are both Postgres.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

missing-template PR is missing required template checklist

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant