Skip to content

feat: time-boxed locks — lock a belief for a bounded window, then let it age out - #1325

Merged
github-actions[bot] merged 7 commits into
mainfrom
feat/issue-1314-time-boxed-locks
Aug 4, 2026
Merged

feat: time-boxed locks — lock a belief for a bounded window, then let it age out#1325
github-actions[bot] merged 7 commits into
mainfrom
feat/issue-1314-time-boxed-locks

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #1314.

Time-boxed locks: aelf lock "<statement>" --for 7d (also w, mo, y, forever) and --until <date|timestamp>, mirrored on MCP as aelf_lock(expires_in=).

What the semantics actually are

The issue is explicit that "after the window it decays like an unlocked belief" is the right intuition and the wrong description, and this implements the description rather than the intuition. scoring.decay() has zero production call sites; nothing automatically moves a posterior. An expired lock stops being injected unconditionally and re-enters ordinary ranked retrieval, where retrieval._apply_temporal_decay already discounts it by 2**(-age/half_life) from created_at. A seven-day-old travel fact therefore re-enters already down-weighted. No new decay machinery, no decay-clock-origin field, and decay() stays unresurrected.

Expiry is a sweep, not a predicate

An idempotent UPDATE at store open flips due rows to lock_level='none', after which every existing lock_level read is correct unchanged. The rejected alternative — a now-aware term on the lock predicates — is unsafe rather than merely tedious: list_speculative_beliefs selects lock_level = 'none' and is the complement of list_locked_beliefs, so the term applied to one and not the other drops an expired lock out of both tiers and makes it invisible to L0 and L1 alike. test_expired_lock_appears_in_both_tiers_correctly asserts presence and absence, because absence alone is equally satisfied by a deleted belief.

Three properties are load-bearing:

  • origin is untouched. The belief was user-asserted; only its injection privilege expired. Rewriting it would reproduce the autolock origin-laundering defect.
  • lock_expires_at and locked_at are retained after the flip, as the trace of why the belief is now unlocked. A non-NULL expiry therefore does not imply "still locked" — which is also why the sweep's lock_level = 'user' term is load-bearing, and why a test fires when it is removed.
  • One lock:expire audit row per flip, in the table aelf unlock writes to, so aelf feed shows the transition.

aelf unlock clears the expiry outright. That differs from the sweep deliberately: an explicit unlock is the user revoking the lock, so the window governing it is moot, whereas in the sweep the window is the explanation.

The index is not optional, and the issue asked for the measurement

The issue says to add an index only if the measurement says so. It does:

sweep predicate, 44,594-belief store
no index SCAN beliefs2.18 ms median
partial index SEARCH … USING INDEX0.001 ms median

This runs on every open of a latency-gated path the hook opens several times per turn, so 2.18 ms/open is real. The index is partial on the same IS NOT NULL term the query states verbatim, which is what lets SQLite match it, and keeps it tiny — permanent locks are NULL and never enter it. With nothing due the sweep is one index probe and takes no write lock. Store open measures 0.60 ms median on the branch, unchanged.

The sweep runs under _run_guarded_migration: a failing sweep leaves a lock injected one session too long, which beats a store that will not open (#1161).

Parsing

Windows resolve to an absolute UTC instant at write time — a relative expression stored as-is would silently re-anchor to whenever it was next read. The parse happens before the store opens, so a typo cannot leave a permanent lock behind, which is the exact outcome the user was avoiding by passing a window.

Calendar units use calendar arithmetic: 1mo from January 31 is February 28, 1y from February 29 is February 28. There is deliberately no bare m — it reads as either minutes or months. A past --until and a zero-length --for are both rejected rather than accepted-and-swept.

Re-locking with --for refreshes. Re-locking without one clears the expiry and says so — the single genuinely ambiguous case, decided on the issue so this did not have to guess.

Visibility

The failure mode is a lock silently vanishing at the moment it mattered. aelf doctor reports both halves and neither substitutes for the other: locks closing within 7 days (the chance to extend before they go) and the last sweep that fired (the only place an expiry that already happened is visible at all). Informational — a lock reaching the end of a window the user chose is the mechanism working.

Verification

  • Full suite 7,052 passed, 69 skipped, 71 xfailed.
  • Every guard mutation-verified to go red: dropping the lock_level='user' term, clearing origin in the sweep, approximating a month as 30 days, accepting a past --until, and applying the window only on the new-lock path each turn at least one test red.
  • Migration: an existing store's locks migrate to lock_expires_at IS NULL and list_locked_beliefs() output is unchanged.
  • Six atomic signed commits, rebased on github/main, discretion grep clean on added lines.

Two failures appeared under full-suite contention only — test_cli_setup_opt_out_sync.py and test_setup_prune_stale.py, both Timeout (>5.0s) at selectors.py:398. Both files are untouched by this diff, both pass in isolation (21 passed, 9.1s), and store open is unchanged at 0.60 ms, so nothing here moved them. That is #1307 — subprocess-driven tests inheriting the 5s default and misreporting contention as a hang. I have now hit it on three unrelated branches today.

One correction to the issue

The spec names the complement list_unlocked_beliefs() at store.py:4160. No such method exists. The argument is entirely sound against list_speculative_beliefs(), which is what actually selects lock_level = 'none', and the design is unchanged — but the reference is wrong and the code says so where it matters.

Not here

Natural-language capture is carved out to #1315 as the issue directs; it needs directive detection still below its precision bar, and this deterministic substrate had to land and be testable first. Also out: resurrecting scoring.decay(), per-belief post-expiry half-lives, expiry inference, and reminder scheduling.

Rollback

Drop the sweep call site. The column goes inert, every lock reverts to permanent. No data loss, no down-migration.

Summary by Sourcery

Add support for time-boxed user locks that automatically expire and revert to ordinary retrieval, with shared parsing, CLI/MCP surfaces, and store-level sweep and observability.

New Features:

  • Allow beliefs to be locked with an optional expiry window via CLI flags (--for, --until) and MCP expires_in, storing an absolute UTC expiry per belief.
  • Expose lock expiry information in aelf locked, MCP tool_locked, and aelf doctor so users can see upcoming expiries and the last sweep that fired.

Enhancements:

  • Extend the belief model and schema with a nullable lock_expires_at field and a partial index, plus an open-time sweep that flips expired locks back to unlocked while preserving audit trace.
  • Introduce a shared lock_expiry utility for parsing and formatting lock windows with calendar-correct arithmetic and robust error handling.
  • Ensure explicit unlocks clear any expiry while automatic sweeps retain it as historical context.

Tests:

  • Add comprehensive tests covering migration behavior, sweep semantics, expiry parsing/formatting, CLI behavior for time-boxed locks, and interaction with unlocks.

Nullable ISO-8601 UTC expiry for a user-lock, NULL meaning no expiry.
Every existing row migrates to NULL and is behaviourally identical to
today's permanent lock, so this commit is storage round-trip only —
nothing reads the column yet.

No CHECK on the ALTER, matching the convention the neighbouring entries
document: ALTER TABLE ADD COLUMN cannot carry one reliably across SQLite
versions. There is no enum to enforce here in any case.
Expiry is materialized by an idempotent open-time sweep rather than
evaluated as a predicate. `lock_level` is read in roughly fifteen
places, and `list_speculative_beliefs` is the *complement* of
`list_locked_beliefs` — a `now`-aware predicate applied to one and not
the other would drop an expired lock out of both tiers at once, making
it invisible to L0 and L1 alike. After the sweep every existing
`lock_level` read is correct unchanged.

Only `lock_level` moves. `origin` is untouched, because the belief was
user-asserted and only its injection privilege expired; rewriting it
would reproduce the autolock origin-laundering defect. `lock_expires_at`
and `locked_at` are retained as the trace of why the belief is now
unlocked, so a non-NULL expiry does not imply "still locked". One
`lock:expire` row per flip lands in the table `aelf unlock` writes to,
so `aelf feed` shows the transition.

Measured before choosing the shape, per the issue: the sweep predicate
is a full `SCAN beliefs` at 2.18 ms median on a 44,594-belief store, and
this runs on every open of a latency-gated path the hook opens several
times a turn. A partial index on the same `IS NOT NULL` term the query
states verbatim takes it to 0.001 ms, so the index is not optional. With
nothing due the sweep is one index probe and takes no write lock.

Runs under `_run_guarded_migration`: a failing sweep leaves a lock
injected one session too long, which beats a store that will not open.

The issue names the complement `list_unlocked_beliefs`; no such method
exists. The argument is sound against `list_speculative_beliefs`, which
is what actually selects `lock_level = 'none'`.
`--for <N>[d|w|mo|y]` (plus `forever`) and `--until <date|timestamp>`,
mutually exclusive, both resolved to an absolute UTC instant at write
time. A relative expression is a way of *saying* an instant, not a thing
to store: written as-is it would silently re-anchor to whenever it was
next read.

Parsing happens before the store is opened, so a malformed window fails
without having first written a permanent lock the user has to notice and
undo. A `--until` in the past is rejected rather than accepted and swept
on the next open, and a zero-length `--for` is rejected as a scripting
mistake rather than read as "expire immediately".

Calendar units use calendar arithmetic: 1mo from January 31 is February
28, and 1y from February 29 is February 28. Only d and w are fixed
length. There is deliberately no bare `m` — it reads as either minutes
or months.

Re-locking with `--for` refreshes the window. Re-locking *without* one
clears the expiry and says so; that is the single ambiguous case in the
surface and the issue decides it, so this does not have to guess.
`aelf unlock` clears the expiry outright — the user revoked the lock, so
the window governing it is moot, which is why this differs from the
sweep, where the window is the explanation for the unlock.

`aelf locked` gains an always-present window column so the values line
up down the listing. `expired` is not a value it can render: the
open-time sweep has already flipped anything due.
The failure mode of a time-boxed lock is it vanishing silently at the
moment it mattered — the user asked for seven days, got seven days, and
has no idea day eight arrived. `aelf doctor` now reports both halves,
and neither substitutes for the other: the forward warning names locks
closing within seven days, which is the chance to extend before they go;
the backward line reports the last sweep that actually fired, which is
the only place an expiry that already happened is visible at all. Both
informational — a lock reaching the end of a window the user chose is
the mechanism working, not a fault.

`aelf_lock` gains `expires_in` taking the same grammar as `--for`,
resolved through the same parser so chat and shell cannot drift on what
"for the next week" means. Absence of the argument on a re-lock clears an
existing window, matching the CLI. The locked listing returns
`lock_expires_at`, null for a permanent lock.
Every guard verified to go red under a mutation, so none of them is
passing vacuously:

- drop the `lock_level = 'user'` term from the sweep and the
  never-locked test fires — without it the sweep re-audits every
  unlocked row carrying a retained expiry, on every open, forever;
- clear `origin` in the sweep and the preservation test fires;
- approximate a month as 30 days and the calendar test fires on
  January 31;
- accept a past `--until` and both the parser and CLI tests fire;
- apply the window only on the new-lock path and all three re-lock
  tests fire.

The both-tiers test asserts presence *and* absence deliberately.
Absence from the locked list alone is equally satisfied by a deleted
belief or by one that fell out of every tier — which is precisely the
bug a `now`-aware predicate on one side of the complement produces, and
the reason the sweep exists.

Driven in-process through `main(argv, out=...)` rather than by
subprocess: nothing here needs process isolation, and a subprocess would
be one more blocking call for the termination policy to bound. Dotdir
and DB are pinned per-test — the live store is repo-local, so an
unpinned test would sweep the developer's own locks and still pass CI.
States the two things a reader would otherwise get wrong: that nothing
decays (an expired lock re-enters ranked retrieval, where existing
age-discounting already handles it — no new machinery), and that expiry
is a sweep rather than a predicate, with the complement-of-the-lock-list
reason that makes the predicate approach unsafe.
@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Aug 4, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a87571a6-573a-4c67-b5e6-78718c46891e

📥 Commits

Reviewing files that changed from the base of the PR and between c34bcb5 and a71ade8.

📒 Files selected for processing (10)
  • CHANGELOG/v4.md
  • src/aelfrice/cli.py
  • src/aelfrice/hook.py
  • src/aelfrice/lock_expiry.py
  • src/aelfrice/mcp_server.py
  • src/aelfrice/models.py
  • src/aelfrice/promotion.py
  • src/aelfrice/review.py
  • src/aelfrice/store.py
  • tests/test_time_boxed_locks_1314.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements time-boxed locks by adding an expiry column and an open-time sweep, CLI/MCP surfaces for specifying windows, visibility tooling, and comprehensive tests and parsing utilities, all without changing existing lock semantics or decay machinery.

Sequence diagram for open-time lock expiry sweep

sequenceDiagram
    participant App as Store.__init__
    participant Store
    participant DB

    App->>Store: _run_guarded_migration(sweep_expired_locks)
    Store->>Store: sweep_expired_locks()
    Store->>DB: SELECT id FROM beliefs
    Note right of Store: WHERE lock_expires_at IS NOT NULL
    Note right of Store: AND lock_expires_at <= ts
    Note right of Store: AND lock_level = LOCK_USER
    Store->>DB: UPDATE beliefs SET lock_level = LOCK_NONE
    Store->>DB: INSERT INTO feedback_history
    Store->>DB: INSERT OR REPLACE INTO schema_meta
    Store->>Store: _commit_mutation()
    Store-->>App: return flipped_count
Loading

Sequence diagram for time-boxed lock creation via MCP and CLI

sequenceDiagram
    actor User
    participant CLI as _cmd_lock
    participant MCP as tool_lock
    participant Expiry as lock_expiry.parse_for
    participant Store

    User->>CLI: aelf lock "<text>" --for <window>
    CLI->>Expiry: parse_for(lock_for, now=datetime.now())
    Expiry-->>CLI: expires_at (ISO) or error
    CLI->>Store: _open_store()
    CLI->>Store: update_belief(lock_expires_at=expires_at)

    User->>MCP: aelf_lock(statement, expires_in)
    MCP->>Expiry: parse_for(expires_in, now=datetime.now())
    Expiry-->>MCP: expires_at (ISO) or LockExpiryError
    MCP->>Store: tool_lock(..., expires_in)
    Store->>Store: _apply_window()
    Store->>Store: update_belief(lock_expires_at=expires_at)
    Store-->>MCP: {kind, id, action, expires_at}
Loading

Flow diagram for lock expiry visibility in aelf doctor

flowchart TD
    A[aelf doctor] --> B[_open_store]
    B --> C["list_expiring_locks(before)"]
    B --> D["last_lock_sweep()"]
    C --> E[_print_doctor_lock_expiry]
    D --> E
    E --> F[print expiring locks within LOCK_EXPIRY_WARN_DAYS]
    E --> G[print last sweep timestamp and count]
Loading

File-Level Changes

Change Details Files
Add lock expiry metadata and index at the storage layer, plus an open-time sweep that materializes expiry and records audit information.
  • Extend beliefs schema with nullable lock_expires_at and corresponding migration.
  • Add partial index on lock_expires_at for efficient expiry sweep.
  • Wire lock_expires_at through Belief model, row mapping, insert/update statements.
  • Introduce sweep_expired_locks() to flip due locks to unlocked, write feedback events, update schema_meta markers, and commit.
  • Add last_lock_sweep() and list_expiring_locks() helpers for visibility of past and impending expiries.
  • Run sweep_expired_locks() under _run_guarded_migration at store open so all lock_level reads see post-sweep state.
src/aelfrice/store.py
src/aelfrice/models.py
Introduce a shared lock-expiry parsing/rendering module used by CLI and MCP to resolve windows to absolute UTC timestamps and format remaining time.
  • Add lock_expiry.py with FOREVER constant, LockExpiryError, parse_for(), parse_until(), and format_remaining().
  • Implement calendar-aware month/year arithmetic via _add_months().
  • Define validation rules for window specs, rejecting invalid or past windows and mapping 'forever' to NULL.
  • Provide compact human-readable remaining-window formatting for CLI/doctor output.
src/aelfrice/lock_expiry.py
tests/test_time_boxed_locks_1314.py
Extend CLI lock/locked/doctor commands to support time-boxed locks, display remaining windows, and warn about upcoming expiries and past sweeps.
  • Update _cmd_lock to parse --for/--until via lock_expiry before opening the store, apply/refresh/clear lock_expires_at on the resolved belief, and print window status.
  • Add mutually exclusive --for/--until arguments to the lock subcommand with detailed help text and semantics.
  • Modify _cmd_locked to show a standardized remaining-window column using format_remaining().
  • Extend aelf doctor store check to query list_expiring_locks() and last_lock_sweep(), and add _print_doctor_lock_expiry() to report expiring locks and last expiry sweep without affecting exit status.
  • Introduce LOCK_EXPIRY_WARN_DAYS and LOCK_EXPIRY_WARN_MAX_LISTED constants for doctor reporting behavior.
src/aelfrice/cli.py
Expose time-boxed lock semantics over MCP, keeping CLI and MCP behavior aligned.
  • Extend tool_lock() to accept expires_in, parse it with parse_for(), and apply/clear lock_expires_at on the resolved belief for created/upgraded/corroborated paths.
  • Return expires_at in MCP lock responses and propagate expires_in through the aelf_lock tool signature and implementation.
  • Extend tool_locked() to include lock_expires_at in listed belief metadata.
src/aelfrice/mcp_server.py
Align unlock behavior and documentation with time-boxed locks and record the feature in the changelog.
  • Update promotion.unlock() to clear lock_expires_at in addition to lock_level and locked_at, with docstring explaining difference from sweep behavior.
  • Add FEEDBACK_SOURCE_LOCK_EXPIRE constant in models and rationale for its valence.
  • Document the time-boxed lock feature, storage/indexing, sweep semantics, and roll-back strategy in CHANGELOG/v4.md.
src/aelfrice/promotion.py
src/aelfrice/models.py
CHANGELOG/v4.md
Add comprehensive tests for migration, sweep semantics, parsing rules, CLI behavior, and visibility guarantees around time-boxed locks.
  • Create test_time_boxed_locks_1314.py covering migration of existing locks to no expiry, sweep behavior, tier visibility, origin/expiry/locked_at preservation, idempotency and audit counts, unlocked-row handling, list_expiring_locks, parsing/formatting, and CLI flows for setting/refreshing/clearing windows and rejecting bad/past specs.
  • Ensure tests pin environment to isolated stores to avoid modifying the developer’s real DB and verify doctor/locked output expectations.
tests/test_time_boxed_locks_1314.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1314 Implement time-boxed lock persistence and expiry semantics, including a lock_expires_at column, migration that preserves existing behavior, an open-time sweep that flips expired user locks to unlocked while retaining origin and audit trail, and no new decay machinery.
#1314 Expose time-boxed locks on user surfaces (CLI and MCP): support --for/--until and expires_in parsing to absolute UTC timestamps, enforce mutual exclusivity and future-only windows, implement relock semantics (refresh window with --for, clear expiry on bare relock or explicit unlock), and show remaining window in aelf locked and MCP responses.
#1314 Provide visibility around lock expiry via diagnostics: have aelf doctor report locks expiring within 7 days (with limited listing and remaining-window formatting) and the most recent sweep that flipped locks, using the sweep count returned at store open.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 1215 changed lines (limit: 200)
  • 10 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-08-04T05:54:26Z]

#1314)

An expired time-boxed lock retains `lock_expires_at` as the audit trace
of why it is unlocked, so a non-NULL expiry does not imply "still
locked". Every path that re-locks an existing belief therefore has to
clear it: setting lock_level='user' while a past expiry is still on the
row hands the next open-time sweep a due row, and the lock is flipped
straight back off.

`aelf lock` and the MCP tool both clear it. `hook._autolock_candidates`
and `review.apply_decisions`'s `lock` verdict construct the same field
update by hand and did not, so both reported success and were silently
undone at the next store open -- auto-lock printing "auto-locked <id>"
on the way. That is the silent-vanishing failure mode the feature exists
to prevent, reached through the two surfaces that lock without a window.

Reproduced against a real store before fixing: belief locked with a past
window, swept to unlocked, re-locked through each path, read back
`user`, then `none` after one reopen. Both now read `user`.

Neither surface accepts a window, so the lock they grant is permanent --
same semantics as `aelf lock` with no `--for`.

Mutations verified red:

  revert the hook fix            1 failed, 36 passed
  revert the review fix          1 failed, 36 passed
  clear every expiry in the      3 failed, 34 passed
    sweep (the opposite defect)

The third is the control: without it both regression tests would pass
against an implementation that dropped every lock_expires_at at open,
which would silently make every time-boxed lock permanent. The new tests
assert after a reopen rather than on the in-memory object, because the
in-memory read is exactly what was already green.
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — one defect found and fixed on the branch (a71ade89)

The design holds up under the checks I could make independently. The sweep-vs-predicate call is right and the reason given is the real one: list_speculative_beliefs selects lock_level = 'none' and is the complement of list_locked_beliefs, so a now-aware term on one and not the other does drop an expired lock out of both tiers. Your correction to the issue is also right — there is no list_unlocked_beliefs at store.py:4160 or anywhere else.

Things I verified rather than took on faith:

  • The sweep really does run every open. _run_guarded_migration does not marker-gate; the marker lives inside each pass, and this pass has none. It also clears a stale migration_failed: row on a later success, so a transient sweep failure does not leave doctor reporting a problem the user no longer has. The "not a one-shot" claim in the body is accurate rather than aspirational.
  • Init ordering is safe, and it depends on a change that landed today. The sweep calls _bump_belief_version (needs _local_scope_id) and _commit_mutation_fire_invalidation (needs _invalidation_callbacks). Both are assigned at store.py:1331 and :1340, ahead of the sweep at :1407. The callbacks one is only ahead of it because fix(store): retry the open-time schema window on SQLITE_SCHEMA #1321 moved that initialiser above the schema window earlier today — worth knowing that this PR is now load-bearing on that ordering.
  • The lexicographic timestamp comparison is sound. Both parse_for and parse_until normalise to UTC-aware and emit .isoformat(), so every stored expiry carries the same +00:00 shape as datetime.now(timezone.utc).isoformat(), and the microsecond-vs-no-microsecond boundary compares correctly in both directions ('+' sorts before '.', which is the right way round here). It holds, but it holds because every writer agrees on the offset spelling — worth a line in the column comment if a third writer ever appears.
  • The --until naive-input path is right. fromisoformatreplace(tzinfo=utc)astimezone(utc), so the mixed naive/aware comparison at line 135 cannot raise, and a bare YYYY-MM-DD means midnight UTC as documented.

The defect: two surfaces re-lock without clearing the retained window

Your own docstring has the premise — an expired lock retains lock_expires_at, so non-NULL does not imply "still locked". The consequence is that every path re-locking an existing belief must clear it, or it hands the next sweep a due row. aelf lock (cli.py:2081-2089) and MCP _apply_window both do. Two paths do not, and both had zero occurrences of lock_expires_at in the whole file:

  • hook._autolock_candidates (hook.py:3530)
  • review.apply_decisions, the lock verdict (review.py:291)

Reproduced against a real store before touching anything — belief locked with a past window, swept to unlocked, re-locked through each path:

autolock reported locked=1
  immediately after:        user
  after next store open:    none     <-- lock gone

review-lock immediately after:      user
review-lock after next store open:  none     <-- lock gone

Auto-lock prints aelfrice: auto-locked <id> (origin→user_stated) to stderr on the way through, and the review verdict lands in report.locked with a review:lock audit row. So both surfaces report success and are silently undone before the user's next session. That is precisely the "a lock silently vanishing at the moment it mattered" failure the visibility section says the feature exists to prevent, reached through the two surfaces that lock without a window — and neither doctor line catches it, since list_expiring_locks filters lock_level = 'user' and the row is none by then.

It is not a corner case reachable only by hand-editing: the population is any belief the user previously locked with --for, which is the feature this PR ships.

Fixed on the branchb.lock_expires_at = None / belief.lock_expires_at = None at both sites. Neither surface accepts a window, so the lock they grant is permanent, matching aelf lock with no --for; I took that from the semantics you already decided on the issue rather than inventing a third rule.

Three regression tests, asserting after a reopen rather than on the in-memory object, because the in-memory read is exactly what was already green. Mutations:

mutation result
revert the hook fix 1 failed, 36 passed
revert the review fix 1 failed, 36 passed
clear every expiry in the sweep 3 failed, 34 passed

The third is the control. Without it both new tests would pass against an implementation that dropped every lock_expires_at at open — which would silently make every time-boxed lock permanent, the opposite defect and equally invisible. It also catches your existing test_sweep_preserves_origin_expiry_and_locked_at, which is the right neighbour for it to collide with.

Smaller notes, no action taken

  • sweep_expired_locks does not filter valid_to IS NULL while list_expiring_locks does. So a retired belief's window is swept but never shown as expiring, and restoring it later returns it unlocked. That is arguably the correct semantics — the window did close — but the two queries disagree about whether retired rows are in scope, and only one of them says so.
  • The --for/--until mutual exclusion, calendar arithmetic (1mo from Jan 31 → Feb 28, 1y from Feb 29 → Feb 28), zero-window and past---until rejections all behave as described; I re-ran those paths rather than reading them.
  • Your read on the two contention failures matches mine: I have hit the same pair on three branches today and they pass in isolation. That is test: subprocess-driven tests inherit the 5s global timeout with no override, so contention reports as a hang (class of #1288) #1307 and it is unrelated to this diff.

Verification

Full suite on the branch with the fix: 7,055 passed, 69 skipped, 71 xfailed. The two ..._when_fastmcp_missing failures are a local dependency-state artifact of the mcp bump and are green in CI. Branch is FF on main. Discretion grep on added lines: clean. Commit signed.

Not labelling ready-to-merge yet — waiting for CI on a71ade89.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 4, 2026
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 4, 2026
@github-actions
github-actions Bot merged commit a71ade8 into main Aug 4, 2026
29 checks passed
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

merge-train: merged a71ade8main via FF push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-08-04T06:06:57Z]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:review Needs review (PR open, awaiting reviewer) author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: time-boxed locks — lock a belief for a bounded window, then let it age out

1 participant