feat(policy): explicit blockers as structured linked context and the derived active set #79
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-63-blockers"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
docs/technical/domain-model.md#task-axesstates the contract: "Blockers are explicit structured user facts in linked context, never a fabricated standard STATUS named BLOCKED."#22 shipped only the matching half of it:
FilterContext.active_blockersexcludes a candidate whose canonical composite item id is in the set, and that exclusion is unit-tested — but nothing in the tree ever derived the set, so in practice it was always empty and the boundary never fired end to end. This PR records the fact, derives the set from the journal, and lets a user resolve a blocker explicitly.Changes
Recording the fact —
src/api/context.rsPOST /v1/context(and thereforelife context add --input -) accepts one new optional member,blocked_by, naming the item that blocks the context'sitem_idby canonical composite id (itm_…). The assertion "item_idis blocked byblocked_by" is recorded as one context fact with a structured link:blocked_item_idis the canonical id the live read established foritem_id(resolve_live_item), not the caller's string, so the fact names exactly the identity the policy filter matches.data.blocker, so the caller sees what was recorded;life context addprints it asBlocked by: … is blocked by ….invalid_inputbefore any write:blocked_bywithoutitem_id(a link needs both ends), ablocked_bythat is not a well-formed item id, and a self-link. "Well-formed" is the item routes' own rule, now stated once insrc/api/types.rs::is_well_formed_idand used bysrc/api/items.rs, the blocker link and the derivation:itm_plus at least eight RFC 4648 base32 characters (the alphabetitem_id_foremits). Prefix-only values (itm_), punctuation (itm_!), hyphens (itm_not-an-id), a lowercase alphabet, a too-short suffix (itm_ABC) and any other prefix are refused, so a typo cannot be recorded as a fact. A blocked item that is not live stays the route's existingnot_found.idempotency_conflict. A context that states no link keeps a byte-identical fingerprint, so previously recorded operations are unaffected.Deriving the set —
src/history/blockers.rs(new)active_blockers(db)is one read over the append-only journal:life_eventsrows of kindcontext_capturedthat carry the structured link, with both ends passingis_well_formed_id, minus every fact the journal's own supersession ledger records as replaced. A row whose ends are malformed is ignored, not trusted — the write path refuses to create one, so a row that has one anyway is not a blocker fact, and failing the whole read over it would deny every next action instead of one task. Nothing else can produce a blocker — no prose, no model output, no external link.Wiring —
src/api/next.rs,src/api/plans.rsrecorded_blockers()fillsFilterContext.active_blockersforPOST /v1/nextandPOST /v1/plans. No second exclusion reason was added: the reason that fires is #22'sExclusionReason::Blocked(reason: "blocked", message "task is blocked by linked context"), and it is observable in the response'sexcludedlist. A journal that cannot be read yields the empty set plus the documentedhistory_incompletewarning — "nothing is blocked" is a claim, and a read that did not happen does not establish it.Resolution — #18's supersession, not age and not silence
A blocker clears when a later fact names the blocker fact as superseded (
supersedes_context_id, the mechanism #18 shipped). The journal stays append-only: the original row is untouched, the resolution is a new fact, andactive_blockersfolds over the chain.The shape rule, promoted —
src/api/types.rs,src/api/items.rsis_well_formed_idmoves besideitem_id_for(with the base32 alphabet now a singleBASE32_ALPHABETconst that both it andbase32_pushuse), so the item routes, the blocker link and the derivation share one rule instead of the item routes having one and this feature having a second, weaker one.Docs —
skills/life/references/commands.mdThe
context addrow now namesblocked_by; nothing else in the skill package changed (no row moved between implemented and not-implemented, sotests/skill_package_tests.rscounts are unchanged).Verification
The baseline on
main(37c8101) is 743 tests; this PR adds 12, all intests/issue63_blockers.rs, and changes no existing test.New integration suite
tests/issue63_blockers.rs(12 tests) — every assertion goes through the real path (mock CalDAV over TCP, the daemon's own router, the daemon's own SQLite journal, the real CLI binary against a served daemon):a_blocker_is_recorded_as_a_structured_fact_and_replays_under_its_keya_blocker_link_needs_both_ends_and_well_formed_item_idsitm_!,itm_not-an-id, lowercase,itm_,itm_ABC, another prefix) are refused with no journal and no outbox row, and a generated id records and excludes (positive control)a_malformed_journal_row_never_produces_a_blockera_blocked_task_is_excluded_from_next_with_an_observable_reasonblocked, the blocking task stays a candidateonly_the_recorded_canonical_item_id_is_blockedrecorded_prose_never_produces_a_blockera_model_judgment_never_produces_a_blockerbasis: semantic) creates no blockeran_unresolved_external_related_to_link_never_produces_a_blockerRELATED-TOlink is not a blockera_resolved_blocker_stops_excluding_the_task_and_is_journaledthe_cli_reports_the_blocked_exclusion_in_its_envelopelife context addrecords the link andlife next --jsonreports the exclusionthe_human_view_names_the_blocked_exclusionlife nextview names the excluded task and the reasonthe_calendar_is_only_ever_readgrep -ri "blocked" src/→ 49 matches in 8 files, and every one is either the new blocker vocabulary (src/history/blockers.rs,blocked_by/blocked_item_idinsrc/api/context.rs, theExclusionReason::Blockedthe domain already had, the two CLI/next comment lines) or the domain's own refusal of such a status:src/domain/lifecycle.rsrejectsSTATUS:BLOCKEDas an unknown status (and asserts that refusal), and the Jev readiness question's lowest legend is "Blocked or unclear". NoBLOCKEDstatus exists anywhere, in iCalendar, in policy or in a string comparison.No existing test was changed, weakened or deleted.
Disclosure — choices this PR made where the spec was silent
cli-api.mddocumentedContext addastext, optional item_id, reason, supersedes_context_idand said nothing about blockers. The minimal shape consistent with that body is one new optional member,blocked_by, on the existingitem_id(the blocked item): the link reuses the item the route already resolves live rather than introducing a second item reference or akinddiscriminator.docs/technical/cli-api.mdgained the field in that row plus a one-paragraph "Blocker context:" note beside the existing payload paragraphs, in the page's own style.docs/is excluded from this repository's git (.git/info/exclude), so that spec edit is local-only and cannot appear in this PR — it is called out here so the choice is reviewable.history_incompleteis reused for an unread blocker set.cli-api.md#error-and-warning-identifiersis the closed list of non-error warning identifiers and this is the one it documents for "the journal could not be read"; no new identifier was invented.POST /v1/plans) reads the same set. The issue names #24 (life next) for the wiring; leavingplanson a different input would have made the same policy filter reach two different candidate sets, so the three-line wiring is included and disclosed here as slightly beyond the literal issue text.life nextprints the exclusion list only when no candidate is eligible — the renderer's existing policy. The exclusion reason is always in the envelope (--json), andthe_human_view_names_the_blocked_exclusioncovers the empty-candidate rendering.invalid_input(validation_failed), matching the sibling body-field validation in the same function (require_text) rather than the route's transport-level 400s.Review response
The first revision of this PR carried a permissive
is_canonical_item_id(prefix plus "no whitespace"), which accepteditm_!anditm_not-an-id; the reviewer reproduced both and the PR-body sentence claiming that a "non-canonicalblocked_by" was refused was therefore false. This revision deletes that helper, promotes the item routes' existing well-formed-id rule so both callers share it, refuses every malformed form at the route (422, no journal row, no outbox row), makes the derivation apply the same rule to a row it reads, and covers each form plus a positive control ina_blocker_link_needs_both_ends_and_well_formed_item_idsanda_malformed_journal_row_never_produces_a_blocker. The two overstated sentences are rewritten above.Defects found outside this PR's scope (reported, not fixed)
history_unavailableis emitted but not documented.src/api/items.rs:179andsrc/api/context.rs(four sites) push ahistory_unavailablewarning, butdocs/technical/cli-api.md#error-and-warning-identifiersis the closed list of non-error warning identifiers and does not contain it. This PR deliberately reuses the documentedhistory_incompleterather than adding to that drift, but the existing mismatch is a real one and is left alone here.ApiFailure::invalid_inputanswers 400 andApiFailure::validation_failedanswers 422, and both serialize the error codeinvalid_input(src/api/mod.rs). The consequence is visible inside one function:POST /v1/contextanswers 400 for an emptysupersedes_context_idand 422 for the sibling field validations (require_text, and the newblocked_bychecks). Not touched here — it is a route-wide decision, not this issue's.docs/is not version-controlled in this repository..git/info/excludeexcludes it, so the specification this work was designed against (docs/technical/*.md, including thecli-api.mdparagraph this PR's request shape is documented in) exists only on the working machine and never reaches a branch or a reviewer. The spec edits for this change are therefore local-only; this is called out because it makes "the spec is the source of truth" unenforceable by review as the repository is currently configured.Related Issues
Closes #63
Refs #22 (the matching half this completes), #18 (the supersession mechanism resolution uses), #24 (the command path the exclusion is observable on)
feat(policy): explicit blockers as structured linked context and the derived active set (#63)to feat(policy): explicit blockers as structured linked context and the derived active setREQUEST CHANGES
Per-criterion review
src/api/context.rs:307-425builds the link from the live-resolved blocked item, stores it underafter_json.blocker, and returns it.a_blocker_is_recorded_as_a_structured_fact_and_replays_under_its_keyverifies thecontext_captured/user_reportrow, structured link, outbox row, replay, and conflict behavior. Unknown request fields remain rejected byContextAddRequest'sdeny_unknown_fields.src/history/blockers.rs:54-93reads only structuredcontext_capturedlinks and removes superseded facts; the blocked end is the live-resolvedApiItem.id, which is the same key consumed atsrc/domain/policy/next_task_filter.rs:641-643. The blocking end is intentionally allowed to be non-live, as disclosed in the PR body. However,is_canonical_item_iddoes not actually validate canonical ID shape; see the finding below.life nexta_blocked_task_is_excluded_from_next_with_an_observable_reasonexercises the router/filter and observes the existingExclusionReason::Blocked;the_cli_reports_the_blocked_exclusion_in_its_envelopeinvokes the reallife context addandlife next --jsonbinaries;the_human_view_names_the_blocked_exclusioninvokes real human-modelife nextand observes the task and “blocked by linked context” message. No second exclusion variant was added.RELATED-TOnever create blockersrecorded_prose_never_produces_a_blocker,a_model_judgment_never_produces_a_blocker, andan_unresolved_external_related_to_link_never_produces_a_blocker. The model test proves acceptance rather than merely dispatch: it requiresbasis == "semantic"and non-empty semantic reasons before checking that no blocked exclusion exists.src/history/blockers.rs:68-90consultsSupersessionLedger;a_resolved_blocker_stops_excluding_the_task_and_is_journaledproves the task becomes eligible, the original structured fact is unchanged, and the later event names the original insupersedes_context_id.SupersessionLedger::supersedes_eventremains true for the original even if multiple later facts target it, so ambiguity cannot silently resurrect it; only a new explicit blocker-bearing fact can block again.BLOCKEDstatussrc/found uppercaseBLOCKEDonly in blocker identifiers/comments and the existing lifecycle rejection test (TaskStatus::parse(Some("BLOCKED"))must fail). No iCalendar status, policy status, or error code was added.src/api/context.rs:326-342starts with the prior semantic object and insertsblocked_byonly when stated. The integration test proves same-key/same-body replay and same-key/different-body conflict; comparison withorigin/mainconfirms blocker-free fingerprint input is unchanged.docs/technical/cli-api.md:93,104contains the minimal request-table addition and blocker paragraph, including resolution and Next/Plan behavior. The PR body accurately discloses thatdocs/is excluded and absent fromorigin/main, so this normative edit is not part of the reviewable commit;skills/life/references/commands.md:69carries the tracked command reference. This is a real repository-level reviewability limitation, but it is pre-existing and explicitly disclosed rather than hidden by this PR.Findings
src/history/blockers.rs:42-45— Reject malformedblocked_byitem IDs.is_canonical_item_idchecks only that the value starts withitm_, has one following character, and contains no whitespace. A direct compiled repro returnedtruefor bothitm_!anditm_not-an-id, althoughitem_id_foremits uppercase RFC 4648 base32 and the existing item-route validator enforces that alphabet and a minimum length. Because this helper gates bothPOST /v1/contextand journal derivation, such a typo is recorded as a structured fact and then excludes the otherwise valid blocked task until explicit resolution. Correct behavior is a 422invalid_input: reuse the item route's well-formed-ID rule (or a shared equivalent) and cover punctuation, lowercase/non-base32, and too-shortitm_values.Verification I ran
nix develop --command cargo test— 754 passed, 0 failed, 0 ignored, across 41 Cargo suite-result lines (including the zero-test binary/doc-test suites).tests/issue63_blockers.rs: 11/11 passed.nix develop --command cargo clippy --all-targets -- -D warnings— completed successfully; 0 errors, 0 warnings.nix develop --command cargo fmt --check— completed successfully with no output.itm_!: true,itm_not-an-id: true,itm_ABCDEFGH: true; the first two are not IDs the canonical generator can issue.PR-body claims
tests/issue63_blockers.rs; no existing test was modified, weakened, or deleted.src/blockedsearch contains no fabricated status.blocked_by[is refused]” and the claim thatactive_blockersrequires “both ends canonical.” Prefix-only values such asitm_!are accepted by the added validator and derivation.cli-api.mdaddition exists, andgit show origin/main:docs/technical/cli-api.mdfails becausedocs/is not versioned. The PR body does not conceal that limitation.Anything I could not check
APPROVE
Follow-up review at head
c30c943: the prior malformed-ID finding is fixed.src/api/types.rs::is_well_formed_idis now the only item-ID shape rule. It requiresitm_plus at leastMIN_ITEM_ID_SUFFIX = 8characters, and every suffix byte must be in the shared RFC 4648 uppercase base32BASE32_ALPHABET.item_id_foruses that same alphabet. The item routes,POST /v1/context'sblocked_byvalidation, andsrc/history/blockers.rsall call this predicate; no second alphabet/shape check or oldis_canonical_item_idremains insrc/.src/api/context.rs:256returns 422invalid_inputbefore item resolution, journal preparation, or commit.a_blocker_link_needs_both_ends_and_well_formed_item_idscovers punctuation, hyphenated, lowercase, empty/short, wrong-prefix and free-text forms, verifies no journal/outbox rows, and uses a generated ID as the positive control.src/history/blockers.rs:79-82applies the same shared predicate to both extracted link ends.a_malformed_journal_row_never_produces_a_blockerinserts a malformed row directly, proves the named task remains a candidate, then proves a well-formed row excludes it.ExclusionReason::Blockedvariant is still the only exclusion reason.STATUS:BLOCKEDwas introduced.docs/limitation. I have no new finding about that pre-existing repository limitation.Verification I ran
nix develop --command cargo test— 755 passed, 0 failed, 0 ignored, across 41 suite-result lines;tests/issue63_blockers.rsnow reports 12 passing tests.nix develop --command cargo clippy --all-targets -- -D warnings— completed successfully, 0 errors and 0 warnings.nix develop --command cargo fmt --check— completed successfully with no output.The prior request for changes is fully addressed; this PR is ready to merge.