2068 Commits
Author SHA1 Message Date
Andrew GrimbergandClaude 3189093aaf CI(release): Pin Python for the SBOM job
The sbom job called python-sbom-action without a python_version
input, so the action's 3.12 default applied. That default cannot
satisfy the project floor of >=3.14.2, and because the action never
binds uv to the setup-python interpreter, uv resolved its own and
downloaded the newest Python matching the floor (cpython 3.15.0).

No project publishes cp315 wheels yet, so every C extension was
compiled from source, overrunning timeout-minutes: 10. GitHub
surfaces that kill as "cancelled", and the dependent grype job was
skipped, costing the release its SBOM artifact and vulnerability
audit.

build-test.yaml already carries this pin, where the same job
completes in roughly 34 seconds. Applying the identical input and
comment to release.yaml restores parity between the two workflows.

Closes #820

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-18 09:47:20 -07:00
Andrew Grimberg 2eb023431d Fix(allocator): Stop notifying on empty registry
Fix(allocator): Stop notifying on empty registry
v3.8.1
2026-10-16 10:32:09 -07:00
Andrew GrimbergandClaude e9d2a25805 Fix(allocator): Stop notifying on empty registry
An absent shared code registry is the normal first-run state: the
registry is new in v3.8.0, so every install upgrading from v3.7.x hit
the persistent notification exactly once with nothing to act on. The
codebase already models that case as benign through registry_missing,
which issuance and the allocator use to self-heal by adopting codes
observed on the locks.

Only a payload that exists but cannot be parsed represents genuine
data loss, so that path keeps its warning log and notification, now
worded in terms of the consequence and the action rather than
internal state while retaining the parse error for diagnostics. The
absent and successful load paths dismiss the notification so stale
alerts left behind by v3.8.0 clear themselves on upgrade.

Load return values are unchanged and all payload cases still fail
closed to an empty registry.

Closes #821

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-16 09:47:51 -07:00
Andrew Grimberg f539fcf2c6 CI(pre-commit): Switch actionlint to fork
CI(pre-commit): Switch actionlint to fork
v3.8.0
2026-10-16 05:54:58 -07:00
Andrew GrimbergandClaude 13a33c853d CI(scorecard): Deny cache access to the callee
actionlint's newer cache policy rules flag the reusable workflow
call as unrestricted: the 'branch_protection_rule' trigger is
low-trust, and without an explicit cap the callee can request
cache writes despite the trigger's read-only default.

Scorecard analyses the checked out tree and caches nothing, so
'cache-mode: none' is the tightest cap that costs us nothing.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-16 05:47:38 -07:00
Andrew GrimbergandClaude 5983011a6a CI(pre-commit): Switch actionlint to fork
rhysd/actionlint has gone quiet; its last release was v1.7.12 in
March 2026. The kjanat fork is actively maintained and shipped
v1.17.0 in September 2026. Pin the dereferenced commit SHA of the
annotated v1.17.0 tag, matching the repository's frozen-rev
convention.

Drop .github/actionlint.yaml. It existed solely to suppress
'unexpected key "queue" for "concurrency" section', a diagnostic
the v1.7.12 schema raised against the valid 'queue: max' in
autolabeler.yaml, and it was documented as removable once the
linter learned the key. The fork knows it, so the file is dead
weight.

The fork's main branch changes the hook to whole-tree, always-run
linting, but the v1.17.0 tag we pin still uses the staged-files
selection we have today, so hook semantics are unchanged here.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-16 05:47:26 -07:00
Andrew Grimberg cdc0d0cbd9 CI(autolabeler): Queue runs instead of cancelling
CI(autolabeler): Queue runs instead of cancelling
2026-10-15 16:20:04 -07:00
Andrew GrimbergandClaude 223db61a24 CI(autolabeler): Queue runs instead of cancelling
'Autolabel PR' is a required status check in the 'Best Practices'
ruleset, so a cancelled run is not free: it leaves a cancelled check
run beside a successful one for the same head SHA, the merge box
reads "Some checks were not successful", and ruleset evaluation
blocks even though the check passed. Adding the 'edited' trigger
made the back-to-back burst that produces this common.

The upstream lfreleng-actions/dot-github template sets
'cancel-in-progress: true' here because the workflow "publishes no
check run that a required status check names". That clause is a
property of the consuming repository and is false for us, so adopt
the queue strategy upstream already uses for
semantic-pull-request.yaml and record the divergence in a comment so
a later sync does not quietly revert it.

'queue: max' is required alongside 'cancel-in-progress: false': the
default 'single' queue holds one pending run and cancels it when a
third event arrives, so a burst still leaves a cancellation behind.

actionlint v1.7.12, the newest upstream release, has no 'queue' key
in its concurrency schema and rejects the valid syntax, so add a
narrowly scoped .github/actionlint.yaml ignore for that one message.
Other unexpected concurrency keys still fail the lint.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 16:13:58 -07:00
Andrew Grimberg 8eb746c941 CI(workflows): Migrate off retiring reusables
CI(workflows): Migrate off retiring reusables
2026-10-15 15:29:36 -07:00
Andrew GrimbergandClaude 39590e82ff CI(autolabeler): Run on pull request edits
Every autolabeler rule in .github/release-drafter.yml matches on the
pull request title, so a title corrected after opening never earns
its label: the pull request lands in no release-note category and
contributes no version increment. 'synchronize' does not cover a
metadata edit, only a push, so add 'edited' to both triggers.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 15:19:02 -07:00
Andrew GrimbergandClaude 644fd2b3ec CI(workflows): Migrate off retiring reusables
lfit/releng-reusable-workflows is retiring. Point the semantic pull
request, OpenSSF Scorecard and CodeQL callers at the maintained
lfreleng-actions lanes, SHA-pinned with version comments.

The CodeQL move is a rename as well as a relocation: the config
input 'codeql_config' is now 'config_file', so the value is passed
under the new name. Scorecard gains 'contents: read' and
'actions: read' because the lane's scan job declares both and a
called workflow cannot hold more permission than its caller.

The semantic pull request job keeps its name, so the required
check 'Semantic Pull Request / Semantic Pull Request' continues to
match. Scorecard and CodeQL check runs do change name, and neither
is named by a ruleset.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 15:18:46 -07:00
Andrew Grimberg d813021206 Feat(const): Add excluded slots config key
Feat(const): Add excluded slots config key
2026-10-15 12:05:16 -07:00
Andrew GrimbergandClaude 84a5a9d920 Fix(allocator): Dismiss notice on last exclusion
Withdrawing the last exclusion left its notification standing forever,
because the per-cycle re-validation returned early when nothing was
stored and never reconciled the notice. Reconcile it from the cycle
instead, where the empty case is visible. Two end-to-end tests also
asserted less than they appeared to: the restart scenario emptied the
adoption gate rather than re-seeding it, and the forced re-issue
scenario previewed a clear rather than a replacement.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:58:53 -07:00
Andrew GrimbergandClaude ba09f80719 Docs(tasks): Mark 024 slot exclusion complete
All 42 tasks across the nine phases are implemented, tested, and
documented, so record the task list state.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:48:59 -07:00
Andrew GrimbergandClaude b8c98bc6bf Test(exclusion): Cover resolution and parsing
The entity-registry resolution path, the schema's slot coercion, and the
config-entry lookup fallback carried only their failure cases, so a
regression in the path an operator actually takes would have gone
unnoticed. Exercise each of them directly.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:48:45 -07:00
Andrew GrimbergandClaude d1bb75df41 Docs(readme): Document slot exclusion recovery
The unreadable-slot section ended by telling operators that no control
exists for a permanently dead slot, which is no longer true. Describe
the new action beside the other operator actions: how to target it, the
three refusals it will not override, the per-refresh re-check that
retires an exclusion on its own, the duplicate-code risk the operator is
accepting, and the fact that saving the options flow drops exclusions.
Point the unreadable-slot notification at the same remedy.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:44:45 -07:00
Andrew GrimbergandClaude 51555f6e22 Test(exclusion): Add end-to-end coverage
Unit tests pin the parts; the spec's success criteria are claims about
behaviour. Add a shared harness and four scenario files that drive whole
cycles: the recovery arc from freeze to exclusion to self-clearing to
relapse, the system-wide unfreeze of three entries from one exclusion,
forced re-issue on a healthy slot of an affected entry, and the
boundaries where an exclusion must not help, must not leak between
entries sharing a lock, and must not reach the planner.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:43:04 -07:00
Andrew GrimbergandClaude 672c6caa30 Feat(allocator): Notify on excluded slots
Operators need to see which slots an entry is ignoring, and the old
unreadable-slot alarm should stop claiming the entry is blocked once a
slot has been excluded. Reconcile a second persistent notification per
entry each cycle: raise it while exclusions stand, dismiss it when the
last one clears, and dismiss it on unload and removal. Surface the
standing exclusions in allocator diagnostics so support requests carry
them without exposing any door code.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:36:33 -07:00
Andrew GrimbergandClaude faac39c0e6 Feat(allocator): Add set_slot_exclusion service
Give operators the only way to create an exclusion: a service naming
exactly one slot, on exactly one entry, with no bulk form and no
force override.

Three guards refuse the request and have no escape hatch. The
important one refuses a readable slot: a readable slot cannot be
causing the freeze, so excluding one could only strand a working
guest code. The guards are skipped entirely when clearing, so an
operator can always return an entry to fail-closed behaviour even
when the slot has since recovered or left the managed range.

Lock targeting narrows by slot before declaring ambiguity, because
several entries may share one Keymaster lock across disjoint ranges.
When clearing a slot that has left every managed range, the persisted
set identifies its owner rather than guessing.

A dry run computes the result, or the refusal that would apply, and
returns before any persistence.

Closes #802

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:29:54 -07:00
Andrew GrimbergandClaude 8242a7116f Feat(coordinator): Revalidate slot exclusions
Persist exclusions in the config entry's data and re-derive them from
the same observation the gate consumes, every cycle. A stored
exclusion is never authoritative: when its slot becomes readable
again, or leaves the managed range, the exclusion is dropped, logged
and rewritten in the same refresh, so no exclusion can outlive the
condition that justified it across a restart.

Data rather than options is deliberate. The options update listener
rebuilds data from options and clears them, so an options save would
discard an exclusion and leave the entry fail-closed, which is the
safe direction. Writing data while options are empty leaves that
listener inert, so persisting never reloads the entry; a regression
pins it.

The existing unreadable-slot notification now names only the slots
still withholding issuance, so an excluded slot no longer reports
itself as blocking.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:25:04 -07:00
Andrew GrimbergandClaude 43150ab204 Feat(allocator): Exclude slots from issuance gate
Teach all three members of the issuance gate to subtract an operator
excluded set: unaccounted_slots, the adoption-completion check, and
the re-issue preview, which reaches the adoption check by its own
path and would otherwise keep refusing force_reissue for the very
slot an exclusion was meant to release.

The excluded set travels beside the cycle observation, never inside
it. The observation is also the evidence that a code is still
physically programmed, so filtering an excluded slot out of it would
let the allocator conclude a live code had vanished and release it.
The accompanying regression pins that separation, including the
orphan-cleanup path, and asserts the observation builder names
exclusions nowhere.

The system-wide pending-adoption set now tracks only non-excluded
unreadable slots, which is what lets one entry's excluded dead slot
stop withholding codes from every other entry.

Nothing produces an excluded set yet; it must be placed in the config
entry's data by hand.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:19:56 -07:00
Andrew GrimbergandClaude f58ec5fe89 Refactor(allocator): Extract claimed slot lookup
Lift the registry claim-set computation out of unaccounted_slots into
a module-level claimed_slots helper so the issuance gate and the
forthcoming slot-exclusion guard share exactly one definition of what
a claimed slot is.

No behaviour changes. The accompanying tests pin the current results
so the exclusion work that follows can be compared against them.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:16:29 -07:00
Andrew GrimbergandClaude 573683c4cf Feat(const): Add excluded slots config key
Introduce the operator slot-exclusion primitives that later phases
consume: a CONF_EXCLUDED_SLOTS key for the config entry's data, a
total parser and deterministic serialiser for the persisted shape,
and the three refusal guards that decide whether one slot may be
excluded at all.

The parser never raises. A persisted value it cannot place is
dropped rather than reported, so a malformed stored value can never
stop a config entry from setting up. The guards have no override by
design: a readable slot cannot cause the issuance freeze, so
excluding one could only ever strand a live guest's credential.

Nothing consumes these yet.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:15:39 -07:00
Andrew Grimberg c24bb5dbc6 Docs(plan): Add slot-exclusion implementation plan
Docs(plan): Add slot-exclusion implementation plan
2026-10-15 11:10:48 -07:00
Andrew GrimbergandClaude b668a03faf Docs(plan): Correct set types and lock resolution
Address two review findings on the slot-exclusion design documents.

The wiring handed revalidate's mapping straight to gates that evaluate
observation.unreadable_slots - excluded, which raises TypeError even
for an empty mapping. The mapping is still what persistence, the FR-006
reason and the notification need, so the documents now convert once at
the call site: frozenset(kept) on the cycle path and frozenset(load())
on the preview path, with every gate parameter and both dataclass
fields typed frozenset[int].

Lock resolution was specified as lock alone, which rejects a supported
deployment: several entries managing disjoint ranges on one shared
parent lock. Slot 7 under entries owning 1-5 and 6-10 would refuse
ambiguous_lock despite belonging to exactly one of them. Resolution is
now slot-aware, as resolve_slot_target already is, and clearing a slot
that has left every managed range identifies its entry through the
stored exclusion rather than by guessing, falling back to the lock's
sole entry as a no-op. The target tests gain the disjoint-range case.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 11:04:07 -07:00
Andrew GrimbergandClaude 4ef58df0a8 Docs(plan): Fix gate wiring and task ordering
Address three review findings on the slot-exclusion plan and tasks.

The re-issue preview reaches the adoption gate twice. Besides the two
consecutive checks the documents already named, it calls
_adoption_would_complete, which calls _adoption_complete. Left on the
new empty default, that call still returns adoption_pending for an
excluded slot after both narrowed checks pass, so force_reissue stays
blocked and FR-019 is unmet in a way that is harder to see than a
missed gate member. The plan, the contract, the quickstart traps and
the gate task now require forwarding the set through that call, and
the regression drives the whole preview path.

Atomic group B consumed a loader that atomic group C created, so
following the prescribed commits left the gate importing a module that
had not landed. The read-only loader moves into group B as its own
task; re-validation and persistence stay together in group C, which is
where the 3.5.0 hazard actually lives.

Dry run was a separate later phase, but the schema accepts the key and
registration happens before the branch existed, so the prescribed MVP
exposed a service where dry_run could persist an exclusion and reopen
issuance. The branch and its tests fold into the handler and service
test tasks, ahead of registration, and the phase is gone.

Tasks renumber to 42 across nine phases.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:55:31 -07:00
Andrew Grimberg 22a7610382 Fix(codes): Label retained generated codes
Fix(codes): Label retained generated codes
2026-10-15 10:55:29 -07:00
Andrew GrimbergandClaude d3a35aeaa5 Fix(diagnostics): Correct synthetic provenance
Two synthesized reservation paths claimed a provenance they could
not know, and the new diagnostics would have published it.

A protected checked-in stay labelled every retained physical PIN
manual_observed, including one the configured generator produces
for the stay window. The check-in shell already computes that
generated code, so it now decides the provenance and passes it in.

Reconstructed ghosts carry no code and inherited the generated
default, so a later adoption of the physically programmed PIN was
reported as generated. Ghosts now record unknown.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:51:09 -07:00
Andrew GrimbergandClaude 29f213477d Docs(tasks): Add slot-exclusion task list
Break the slot-exclusion plan into 43 ordered, dependency-aware tasks
across ten phases, each mapped to the spec's FR and SC identifiers.

The ordering is a safety ordering rather than a story ordering. The
gate learns to honour an excluded set and the system learns to retire
one before anything can create one, so the tree never holds a persisted
exclusion that nothing re-validates. That state is the 3.5.0 failure
mode which commit 0622057 reverted and which issue #607 redesigned
away.

Three atomic-commit groups are called out with the reason each must
land whole, including the three issuance gate members, which behave
inconsistently for one slot if only some of them ship.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:44:33 -07:00
Andrew GrimbergandClaude 1ac04ca2bd Fix(diagnostics): Keep code provenance
The allocator write-back overwrites code_source with the allocation
lifecycle result, so by the time the desired plan is built a
retained generated code and a manual PIN carry the same label and
the published diagnostic cannot answer the question it exists for.

Record the calendar-build provenance in a separate
code_provenance field that the allocator never touches, and
publish both: code_source for the allocation lifecycle, and
code_provenance for generated versus manual_observed.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:42:49 -07:00
Andrew GrimbergandClaude ae64f54284 Docs(plan): Add slot-exclusion implementation plan
Turn the merged slot-exclusion specification into a Phase 0/1 design
for issue #802, covering the technical approach, the files created and
modified, the persisted shape, the test strategy, and the risks.

The central decision recorded here is that exclusion is applied at the
three issuance gate members and never baked into the cycle observation.
Filtering at the source would make an excluded slot look empty to
_owner_still_programmed, letting the orphan sweep release a code that
may still be physically programmed on a lock, which FR-018 and SC-014
prohibit. The plan enumerates the three gate members, including the
re-issue preview's own raw rejection, which a fix to the shared helpers
alone would miss.

Research also records the specification citations that drifted past
6a6602b, and corrects a method the spec names but that does not exist.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:41:34 -07:00
Andrew GrimbergandClaude a53e5674c8 Feat(diagnostics): Publish reservation code source
Reservation.code_source was write-only state: assigned across the
reservation builder, check-in protection, and the allocator
write-back, but never read by any production path (issue #803).

Publish it in the per-reservation desired-plan diagnostics, where
it explains why a reservation holds the code it holds - generated,
manual_observed, adopted, allocated, collision_resolved, or
unallocated. That distinction is what is needed to debug
code-rotation behaviour after the date-shift fix in issue #801.

The value is a label only. The coordinator scrubber still strips
slot_code, pin, and code keys, so no door code is exposed.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:35:57 -07:00
Andrew GrimbergandClaude e00654acae Fix(codes): Label retained generated codes
A door code that exactly matches what the configured generator
produces for the programmed window was reported as
"manual_observed" whenever rotation was disabled or the rotation
window had closed. That label is wrong: the code was generated,
it was merely retained.

Classify such codes as "generated" and document the residual
ambiguity carried over from issue #736 - an observed PIN that
differs from the generator output cannot be distinguished from a
code produced by an earlier generator configuration.

Only the diagnostic label changes; the code returned to the caller
and the rotation flag are untouched.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:35:46 -07:00
Andrew Grimberg edb6a7feb7 Docs(spec): Add slot-exclusion recovery spec
Docs(spec): Add slot-exclusion recovery spec
2026-10-15 10:11:56 -07:00
Andrew Grimberg 6a6602ba80 Feat(allocator): Notify on unreadable lock slots
Feat(allocator): Notify on unreadable lock slots
2026-10-15 10:05:48 -07:00
Andrew GrimbergandClaude 1464ee63c3 Docs(spec): Add preview check to the gate scope
The re-issue preview independently rejects any non-empty raw
unreadable set before it consults the two shared gate helpers.
Making only those helpers exclusion-aware would leave force_reissue
blocked, contradicting the requirement that preview is unblocked on
the same terms as ordinary issuance.

Name the preview's own check as a third member of the gate scope and
correct the claim that reusing the shared helpers unblocks preview
for free.

Refs #802

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:04:45 -07:00
Andrew GrimbergandClaude cf6fec1825 Fix(allocator): Report every unreadable slot
The first cut of the unreadable-slot notification reconciled against
the allocator's unaccounted set, which excludes unreadable slots that
this entry already owns in the registry. The adoption gate blocks on
any unreadable slot, so an owned-but-unreadable slot dismissed the
notification while issuance stayed frozen, potentially system-wide
after a restart. Reconcile the notification and the gate annotation
against the observation's full unreadable slot set instead.

Correct the remedy as well. Deleting a slot's Keymaster entities does
not lift the freeze, because the coordinator still walks its configured
slot range and classifies missing entities as unknown. Point operators
at restoring readable entities, or at narrowing the entry's managed
range when the slot is never coming back.

Finally, clear the notification when an entry unloads or is removed, so
a disabled or deleted entry cannot strand a warning that no later
refresh would ever dismiss.

The issuance gate itself is still untouched.

Refs #802

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 10:00:35 -07:00
Andrew GrimbergandClaude 77eba4e081 Docs(spec): Scope exclusion to the issuance gate
Copilot review surfaced three accuracy problems in the first draft.

Filtering the shared cycle observation for every reader would have
weakened code-release evidence: the still-programmed check treats a
slot with no observed code and no unreadability as unprogrammed, and
orphan cleanup consumes the same observation. Since the claim guard
is scoped to the addressed entry, an exclusion could have released a
removed entry's code without proving it absent. Require gate-only
filtering and forbid releasing on the strength of an exclusion.

Adoption also requires readable coded slots to be accounted for, so
excluding every unreadable slot is not sufficient on its own.

An entry with incomplete adoption submits no allocation requests, so
it never surfaces the adoption_pending refusal; only the healthy
entries do.

Refs #802

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 09:57:24 -07:00
Andrew GrimbergandClaude 8dab8dcc26 Feat(allocator): Notify on unreadable lock slots
A single managed Keymaster slot that cannot be read freezes door code
issuance, but the condition was log-only: nothing surfaced in the Home
Assistant UI, and the one adoption-gate notification that could fire
named config entry IDs rather than slots. Operators had no way to learn
which slot was at fault or what to do about it.

Raise a per-entry persistent notification that names the entry, the
lock and the offending slot numbers, states that issuance is withheld,
warns that at startup this withholds issuance for every Rental Control
entry, and points at the Keymaster remedy. Dismiss it once the slots
report again so a repaired lock cannot leave a stale notification
behind. Annotate the adoption-gate message with the unreadable slots
blocking each pending entry, and document the condition and its remedy
in the README.

This is a visibility change only. The fail-closed gate is deliberate -
the code space is shared system-wide, so adopt-before-issue has to be
global - and its behaviour is unchanged.

Refs #802

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 09:50:06 -07:00
Andrew GrimbergandClaude 5a42be19f2 Docs(spec): Add slot-exclusion recovery spec
A single permanently unreadable managed slot freezes door-code
issuance. The freeze is entry-wide during a cycle, and system-wide
after a restart because the shared pending-adoption set lives on
the singleton allocator, so one dead slot at one property stops
codes at unrelated properties.

The gate is correct and must stay fail-closed: an unreadable slot
may hold a code the integration cannot see. Specify an operator
remedy that changes only what the allocator observes, leaving the
gate and its system-wide scope untouched.

Covers the set_slot_exclusion service contract, three refusal
guards with no override, mandatory per-cycle re-validation with
auto-clear, config-entry durability justified against the 3.5.0
persisted-state revert, operator-visible surfaces, the residual
collision risk, and the alternatives rejected.

Refs #802

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 09:48:54 -07:00
Andrew Grimberg cb4544ca6d Fix(codes): Rotate date-based codes on date shift
Fix(codes): Rotate date-based codes on date shift
2026-10-15 08:42:57 -07:00
Andrew GrimbergandClaude 2be00141da Fix(sensor): Verify the PIN before publishing it
Set confirmation only checks the slot name, so a confirmed set is not
evidence that the requested PIN reached the lock. Caching it let
guest-message automations advertise a code that could be absent,
unavailable, or different from the one programmed.

Publish a written code only when the lock's PIN entity reports it,
and otherwise drop the cached observation so the next refresh re-reads
the lock.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 08:34:31 -07:00
Andrew GrimbergandClaude 3dd2919aaa Fix(codes): Keep rotation PIN out of repr
code_rotation_origin carries the programmed PIN, so the default
dataclass repr leaked it whenever a rotation was pending, bypassing
the redaction already applied to slot_code.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 08:25:25 -07:00
Andrew GrimbergandClaude de12eda955 Fix(codes): Bind rotation to its origin slot
The reservation builder pairs same-name stays by date order while the
allocator matches by identity, so reordering two stays can hand the
shifted reservation a different physical slot than the one whose code
produced the rotation signal. Suppressing adoption there could leave
an unrelated manually set PIN open to being overwritten.

Record the slot and code the signal came from and require the
allocator to have matched that exact slot before adoption is
suppressed.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 08:18:21 -07:00
Andrew GrimbergandClaude 408d3ae79a Fix(store): Keep mappings across a replacement
A replacement now reports its intermediate confirmed clear, and the
Store synchronizer treated every confirmed-clear slot as emptied. A
successful overwrite therefore discarded the newly programmed
reservation's persisted mapping along with its aliases and
fingerprint history.

Only treat a clear as terminal when no confirmed set follows it on
the same slot. A clear with no replacement, or one whose replacement
is unconfirmed, still removes the old mapping.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 08:17:49 -07:00
Andrew GrimbergandClaude 22b53751d0 Fix(sensor): Report the clear behind a replacement
A replacement returns only its set result, so a confirmed clear
followed by an unconfirmed set looked like a write that never
happened. The sensor cache replay then kept publishing the old PIN
even though it had already left the slot.

Report the intermediate confirmed clear alongside the set result so
callers replaying a cycle can see the slot was emptied before the
replacement was attempted.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 08:07:03 -07:00
Andrew GrimbergandClaude cac7f3e660 Fix(sensor): Publish codes after confirmed writes
The sensor-facing observation cache is snapshotted from the lock
before the plan runs and was never refreshed afterwards. A confirmed
date-shift replacement therefore left calendar sensors and guest
message automations publishing the PIN that had just been cleared,
until the next refresh re-read the lock.

Replay the plan's operation results into the cache, trusting only
confirmed operations: a confirmed set publishes the new code, a
confirmed clear drops the stale one, and anything unconfirmed or
failed leaves the observation untouched so the next cycle re-reads
the lock rather than advertising a write that may not have landed.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 07:56:49 -07:00
Andrew GrimbergandClaude 789c188faf Fix(codes): Scope rotation gate to the local slot
The gate accepted any registry owner for the observed code, but the
same PIN can be programmed on two locks. Ownership held by another
entry, lock or slot does not protect this one: the release guard only
inspects the lock recorded on the owner it releases, so sweeping that
other owner away would make the PIN issuable while still programmed
here.

Require an owner matching this entry, lock and slot before suppressing
adoption, so a code is only left unadopted when its retention is
guaranteed by its own owner record.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 07:47:39 -07:00
Andrew GrimbergandClaude 871de15664 Fix(codes): Limit rotation to future arrivals
should_update_code is documented as updating codes when future events
change their dates, and PR #225 bounded rotation to arrivals that had
not happened yet. The date-shift rotation added here dropped that
boundary, so extending checkout during a stay could replace a PIN the
guest was actively using.

Require both the programmed window and the requested window to start
in the future before rotating. An in-progress stay keeps its code and
receives a dates-only update, matching the behaviour of a disabled
should_update_code.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 07:38:19 -07:00
Andrew GrimbergandClaude 545f7f6bb6 Fix(codes): Require owned code before rotating
The registry-lost flag is cleared globally by resolve_cycle once the
first entry in a cycle finishes adoption, so a later date-shifted
entry could see a healthy allocator while its own programmed code was
still absent from the registry. Rotating there would drop the only
reservation on a PIN that is still physically on the lock, letting a
failed replacement write leave that PIN issuable to another booking.

Gate rotation on the direct invariant instead: the observed code must
already be owned in the allocator registry. That holds in the healthy
path because the release guard retains codes that are still
programmed, and it fails safe during any recovery pass regardless of
entry ordering.

Co-authored-by: Claude <claude@anthropic.com>
Signed-off-by: Andrew Grimberg <tykeal@bardicgrove.org>
2026-10-15 07:22:32 -07:00