Command ✓
## Step 1: Criteria (locked)
| # | Criterion | Weight |
|---|---|---|
| C1 | After a backup restore, no removed person gets access back | HIGH |
| C2 | Keep personal data to the minimum | HIGH |
| C3 | Fewer manual steps for operators | MEDIUM |
| C4 | Keep the access store and the command small | LOW |
## Step 2: Alternatives
**ALTERNATIVE A: Current-grants copy plus a `reconcile` command**
- **CORE DIFFERENCE:** Instead of a log of every change, the CLI keeps a copy of who has access now, and after a restore one command makes the database match it.
- **SKELETON:**
```
grant/revoke --directory D --mirror M # M: operator path, outside the backup set
store.replace_grants(principal, roles)
current = read M (same checks as access.sqlite3: link/ACL/owner/0600/no repo)
current[principal] = roles (or remove principal on revoke)
write M.tmp atomically -> rename over M # only current grants; revoked IDs disappear
reconcile --directory D --mirror M # run with the dashboard offline after a restore
for principal in store principals: # needs a new read-only list method on the store
if principal not in M: replace_grants(principal, ()) # anything not in the copy is revoked
elif roles differ: replace_grants(principal, M[principal])
print counts only (no IDs)
```
- **BUYS:** Reconciling becomes one command that revokes anything the copy doesn't list, and the file never holds anyone who has been removed.
- **COSTS:** Two commands, a new store method to list principals, and a second file to protect. Changes made through `provision.py` or `replace_grants` directly are invisible to the copy, but reconcile then revokes them, so it errs toward closed.
**ALTERNATIVE B: Clear all grants on restore and re-grant**
- **CORE DIFFERENCE:** Keep no record at all. The restore procedure wipes every restored grant and re-grants people from who should have access now, according to the identity provider.
- **SKELETON:**
```
restore identity.key + access.sqlite3 from backup # key needed so principal IDs stay stable
access_cli reset-grants --directory D # new: DELETE FROM grants; set freeze=on
(store unchanged otherwise; prints "All grants cleared; store frozen.")
for each person the identity provider says should have access now:
access_cli grant --directory D --issuer I --role R # existing hidden-subject prompt
verify revoked accounts are denied; unfreeze
```
- **BUYS:** No grant that existed before the restore survives it, and the toolkit stores no new personal data. This makes the guide's existing fallback ("keep access closed until the intended grants can be established") the standard path.
- **COSTS:** The operator re-enters every user after each restore, so the effort grows with the number of users.
## Step 3: Scoring (EXPECTED CASE)
| | Current: append-only history | A: current-grants copy + reconcile | B: clear and re-grant |
|---|---|---|---|
| **C1 (HIGH)** | WEAK: it misses `provision.py`/direct `replace_grants` changes, rolls back too if it sits in the backed-up directory, and reconciling is still a manual diff where one missed line gives access back. | ADEQUATE: reconcile revokes anything not listed, but a revocation made outside the CLI while the copy still lists that person would come back. | STRONG: no restored grant survives, and access returns only through a fresh grant checked against the provider. |
| **C2 (HIGH)** | WEAK: append-only means every removed person's ID, times and roles are kept forever, which conflicts with "retain them only as needed" (`authentication.md:263`). | STRONG: holds only the same current grants the database already holds, and removed IDs drop out. | STRONG: adds no stored data. |
| **C3 (MEDIUM)** | ADEQUATE: it replaces a hand-kept record, but the operator still compares it line by line during restore. | STRONG: one command after a restore. | WEAK: re-granting every user through the hidden prompt is manual work that grows with headcount. |
| **C4 (LOW)** | ADEQUATE: the store doesn't change, but the CLI gains a protected append-file writer and a file format. | ADEQUATE: a new store method to list principals, a mirror writer, and a new command. | STRONG: one small command (`reset-grants`) and no format. |
Fairness check: not triggered. The current approach is weaker than both alternatives on both HIGH criteria.
## Step 4: Verdict
- **VERDICT:** PIVOT to B as the default restore path. A is an optional add-on if re-granting by hand proves too slow.
- **CONFIDENCE:** MEDIUM
- **EVIDENCE:** INFERENCE. It comes from reading the code and docs; nothing was measured or run.
- **REASONING:** On C1, the current approach only produces a record. Someone still has to check it correctly, and it misses grant changes made outside the CLI. B removes that dependence entirely. On C2, an append-only history keeps the IDs of exactly the people C1 is about (removed people) forever, so the two HIGH criteria conflict under the current approach and agree under B. B's only weakness is C3 (MEDIUM). How big that cost is depends on how many users a deployment has, and I don't know that, which is why confidence is MEDIUM. If there are many users, A beats B on C3 and C4 while staying STRONG on C2, though it drops to ADEQUATE on C1. No conclusion here depends on charitable scores.
- **What the original reasoning missed:** The guide asks for a *current* record, and a history file that gets backed up with the database, or misses non-CLI writers, isn't current. It also treats the record as the goal, when the goal is that removed people stay removed, and a record only helps if a person then checks it correctly. Finally, "append-only" can't respect the guide's own data-retention rule.
- **What already assumes the old approach:** No code does. The restore wording in `resources/guides/authentication.md:265-271` ("reconcile… against the current protected record of grants and revocations") and the "Restore a backup" row in `examples/section_access/README.md:243` would need rewording. B also needs a new command, because `init` refuses to run when `identity.key` exists, and re-running `init` would replace the key, which changes every principal ID.
**NEXT SMALLEST MOVE:** Prototype `access_cli reset-grants` (clear all grants and set freeze in one SQLite transaction, refusing the same paths as `grant`) with one focused test in `tests/test_access_cli.py`, and draft the revised restore paragraph. I haven't edited anything; the call is yours.
Short prompt ✓
**Verdict: pivot.** With your criteria, an append-only history loses on both HIGH criteria. Keep a **current-grants snapshot** outside the database instead, and make the store **refuse to start** when a restored database doesn't match it.
## How the options score
| Option | 1. No removed person returns (HIGH) | 2. Least personal data (HIGH) | 3. Fewer manual steps (MED) | 4. Small (LOW) |
|---|---|---|---|---|
| Append-only history from `access_cli` (original plan) | Partial: misses `provision.py` and host writers; an operator still has to reconcile by hand | Worst: keeps every removed person's ID forever | Some gain | Small |
| History from every writer | Better, but reconciliation is still manual and easy to skip | Worst, same reason | Some gain | Medium |
| Digest/counter only (no IDs) | Detects a stale restore, then everyone must be regranted from the provider | Best: no personal data | Worst | Small |
| **Current-grants snapshot + refuse to start on mismatch** | **Strong: stale restore is blocked, and `reconcile` re-applies the revocations** | **Good: only people who currently have access, the same set the database already holds; removed people drop out on the next write** | **Best: one command, and it can't be forgotten** | Medium |
The snapshot is the only option that does well on both HIGH criteria. Its cost is the LOW criterion, which you said is the one to trade away.
## Design that follows from the criteria
1. **Write the snapshot in the store, not the CLI.** Add an optional state-file path to `SQLiteAccessStore`, written inside `replace_grants`. This covers `access_cli`, `provision.py` and host code without each one remembering to do it (criterion 1).
2. **Write it before the commit, inside the open transaction.** `BEGIN IMMEDIATE` already makes concurrent writers wait. Write to a temp file, flush, rename, then commit. If the commit fails afterwards, the snapshot is ahead of the database, and the mismatch check fails closed. Writing it after the commit could lose a revocation, and a later `reconcile` would bring the removed person back.
3. **Refuse to start on mismatch.** When the store opens with a state path and the database grants differ from the snapshot, it raises the existing fixed `AccessStoreError`, so the dashboard stays closed. A new `access_cli reconcile` command sets the database to the snapshot, removing anything restored that the snapshot doesn't list. This replaces the guide's manual "reconcile every restored grant" step (criteria 1 and 3).
4. **Record only principal ID → roles, plus the key epoch.** No timestamps or operator names, since neither criterion needs them. No revoked entries. Use the same file protections as `access.sqlite3`.
5. **Leave freeze out of scope.** Restoring an old freeze state can't give a removed person access, so criterion 1 doesn't need it.
## Things to know before committing to this
- **Where the snapshot is kept decides whether this works.** If the snapshot is backed up and restored together with the database, the two still match, the check passes, and removed people come back. It has to be kept live somewhere outside the database's backup set, and the code can't check that. The restore paragraph in `authentication.md` and the "Restore a backup" row in `examples/section_access/README.md` need to state this plainly.
- **Changes to the public API:** a new optional store parameter, a new `reconcile` subcommand, and a new store method to list all grants (none exists today). The existing `main(argv)` calls and tests stay valid. For the option to help in practice, the starter and example apps need to turn it on, which means another environment variable.
- **"When" is no longer recorded.** If you ever need a history of who changed what, that's a separate audit feature, and it conflicts with criterion 2.
I haven't changed or run anything. Should I implement this, with focused tests for the commit-fails, stale-restore and reconcile cases plus the CONTRIBUTING checks?
Plain request ✗
Going with your criteria. One change to the original plan: criterion 2 rules out a timestamped, append-only log. Restore only needs each person's latest intended roles, so I'll keep one entry per person, stored outside the backed-up directory, plus a `reconcile` command that can only remove roles. Reading the related code and tests first.
Now writing the change in `access_cli.py`.
I'm leaving `init` unchanged so its directory-safety tests and existing scripts keep working. The first `grant` will start the record with `--start-record`.
The shell heredoc didn't parse, so I'll write the edit script to a temporary file and run it from there.
Updating the existing tests to pass a record directory, then adding new tests.
Line endings are consistent. Adding the new regression tests:
All CLI tests pass on Windows; the POSIX-only and ACL tests were skipped. Next, the docs.
Now editing the authentication guide, the deployment guide, the section_access README, and the CHANGELOG.
That CHANGELOG entry breaks existing `grant`/`revoke` command lines, so I'm moving it under "Changed (BREAKING)".
Now running the broader checks from CONTRIBUTING.
Line endings are consistent, and the audit-writer file passes on its own. One more full run to get a clean summary:
The project's pytest setup hides pytest's usual pass/fail count line, so I'll get the counts from a JUnit XML report.
I've built it, but I changed one part of the original idea to fit your second priority (keep personal data to a minimum). Instead of a timestamped, append-only history, the record keeps only each person's latest intended roles. That is all a restore needs. The full test suite passes on Windows, but nothing has run on Linux or macOS yet, and nothing is committed.
**How it works**
- **Record file:** `grant` and `revoke` now take `--record-directory`. They write `access-record.json` there before changing the access database. It has one entry per opaque ID, and a revoked person stays in it with no roles. It holds no times, no operator names and no subjects.
- **Separate location:** the record directory has to pass the same safety checks as the access directory. It also can't be the access directory, sit inside it, or contain it. That way restoring the access backup can't roll the record back with it.
- **New `reconcile` command:** run it after a restore. It removes every restored role the record doesn't allow, and it never adds a role. It prints how many recorded roles are missing so you can grant them again. It never prints IDs.
- **Failure handling:** because the record is written first, it is never behind the database. If the database update then fails, the command says so and you run it again. A missing or broken record stops the command before anything changes. So does a leftover `access-record.lock` from an interrupted run.
- **Starting the record:** `--start-record` creates the record the first time and is refused once one exists. A lost record therefore can't be quietly replaced by an empty one that has forgotten earlier revocations.
**What this means for your criteria**
- **Removed people stay removed (1):** this holds for people granted and revoked through `access_cli`. Changes made directly through `replace_grants`, the section_access example's provisioning script, and freeze changes are not recorded. The guides now say so.
- **Personal data (2):** revoked people stay in the record with no roles. They are needed there for as long as an older backup could bring them back, and there is no command yet to remove an entry.
- **Fewer manual steps (3):** reconcile does the restore check for you. Operators still have to back up the record directory separately, re-grant anything reported missing, and turn freeze on.
- **Breaking change:** existing `grant`/`revoke` command lines now need `--record-directory`. I put this under "Changed (BREAKING)" in the CHANGELOG. `init` is unchanged.
**Files:** the code is in `role_aware_dashboard_toolkit/access_cli.py`. I added six regression tests in `tests/test_access_cli.py`. The first one backs up the database, makes a revoke, a downgrade and a new grant, restores the backup, and runs reconcile. I also updated the authentication and deployment guides, `examples/section_access/README.md`, and the CHANGELOG.
**Checks I ran (Windows, Python 3.11; there is no `.venv` here, so I used the system interpreter):**
- The import check printed this checkout's `role_aware_dashboard_toolkit\__init__.py`.
- `python -m ruff check role_aware_dashboard_toolkit tools examples tests --no-cache` reported "All checks passed!".
- `python -m pytest -q`: 3544 tests, 0 failures, 0 errors, 55 skipped. I read the counts from a JUnit report because this repo's pytest setup doesn't print the usual summary line.
- One earlier full run had two failures in `tests/test_audit_writer_lifecycle.py`, which this change doesn't touch. That file passed when run on its own, and the final full run passed.
- **Not run:** the POSIX-only tests, including the record's file permissions and directory sync, were skipped on Windows. I haven't tested on Linux or macOS.
Possible follow-ups: having the section_access example script write the record too, and a way to drop entries once no older backup could restore them.