Repository navigation
feat!: refuse derived-field writes and verify a TeamIteration clear - #20
Merged
Merged
Conversation
An Assignable's Effort, EffortCompleted and EffortToDo are each the sum of the corresponding field over the item's RoleEfforts. TargetProcess answers a direct write to one with a success status whether it stored the value - the item has no RoleEffort rows to override it - or recomputed the field from those rows and changed nothing, and the response does not say which. The outcome therefore depends on the entity's other records rather than on the request, and a caller cannot tell the cases apart without reading the source collection first. So the refusal is before the request, alongside check_include and check_where rather than the verification that follows a write: no re-read distinguishes "stored because nothing overrode it" from "recomputed back to the same number", while the RoleEffort route the message names is deterministic whatever the collection holds. The set is declared once, in resources/_derived.py, and BaseResource.derived_fields applies it on create, update, create_many and update_many; AssignableResource carries the declaration for the six work-item managers, and the generic entities path mirrors it for every spelling of their collections and of the untyped Assignable-derived ones, so the generic accessor is not the way round the typed guard. BREAKING CHANGE: a create or update naming one of the three roll-ups now raises ValueError before sending. Write the role's own row through client.role_efforts - the message carries the query that finds it - or pass allow_derived=True to send the write as before. The recorded write fixtures take that escape hatch and are the evidence for the claim above: each entity they write is created in the same run and carries no RoleEffort, so TargetProcess stores what was sent, which is what makes their read-back provable. RoleEffort's own effort fields are stored as written and stay unguarded, and the model and resource docstrings now say where a work item's effort lives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TargetProcess cascades a parent's team iteration onto its children, so a child's explicit null is answered with a success status whether the field cleared, was discarded, or cleared and was immediately re-acquired from the parent. Nothing on the response says which, so an item that should be unscheduled silently stays in its parent's iteration - and where a null iteration is itself a meaningful state, that misreports whether the work was part of the committed plan. clear_team_iteration on the six work-item managers sends the clear and then checks it. Deliberately not a mechanism of its own: it is update(id, TeamIteration=None, verify=True) - the same write, the same single narrowed re-read, the same comparison - with the failure re-raised as TeamIterationCascadeError, because the remedy is not a retry but clearing or detaching the parent, or moving the item out from under it. The new error subclasses VerificationError, so a caller already handling "the re-read did not show the write" catches it too, and the observed value stays in mismatches under TeamIteration. There is no verify=False: an unverified clear cannot be told from a failed one, which is the reason the method exists. A caller who wants the write without the check calls update directly. This is the opposite shape to the derived-roll-up refusal in the preceding commit - that one cannot be checked after the fact and so fails before sending; this one can only be known after the write - and the two share no code path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The write-path recording rules describe Effort as the numeric field each update sends and reads back. It is now a derived roll-up the resource layer refuses a direct write to, so those sends pass allow_derived=True - and the reason they may is the reason they are provable in the first place: each entity is created in the same run and carries no RoleEffort to recompute the field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two tracked files were at the line-count ceiling the pre-commit and CI gate enforces: SPEC.md at 999 of 1000, and docs/USAGE.md at 992 after the two write guards were documented. Neither could take another paragraph. Both sections move whole, following the precedent SPEC.md already sets for its Testing Approach section: the content is unchanged and a stub in its place names the new file and what is in it. SPEC.md's Observability section goes to docs/observability.md - it describes what the library emits rather than how it behaves against the API, so it is the one large section that is not part of the behavioural contract the rest of the file carries. USAGE.md's Logging time goes to docs/time.md. Nothing is deleted: each moved block round-trips byte for byte against the revision it came from, with only the heading level shifted for a standalone file and a provenance sentence added. The one cross-reference this breaks - USAGE.md's own link to the custom-activity subsection - now points into docs/time.md; no other file referenced either section's anchors. SPEC.md is 917 lines and docs/USAGE.md 892, leaving 83 and 108 of headroom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The derived-field refusal and the verified TeamIteration clear are behavioural contracts, so they belong beside the ones they sit between: the refusal next to the silently-ignored queries it shares its fail-before-sending posture with, the clear after the verified writes it is built from. The derived-field contract states what the recorded evidence actually supports - that such a write can land and can equally be recomputed away, that both carry a success status, and that no re-read separates them - and attributes the zero-RoleEfforts mechanism as the likely explanation rather than established behaviour. A specification that asserted an unproven mechanism would be the very failure these two guards exist to prevent. Also records TeamIterationCascadeError in the exceptions list and allow_derived in the resource signatures, which repairs the Public API Surface drift the new exception introduced: the exceptions listed and the exceptions defined are now in sync, and the surface's remaining gaps are unchanged from the base revision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository: man8/targetprocess-py/.coderabbit.yaml Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
man8
approved these changes
Sep 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TargetProcess answers some writes with a success status that does not mean the
write took effect. This adds a guard for two such fields, and the two guards are
deliberately opposite shapes, because the two failures are knowable at different
moments.
A work item's effort roll-ups cannot be checked after the fact, so they are
refused before sending.
Effort,EffortCompletedandEffortToDoare eachthe sum of the corresponding field over the item's
RoleEfforts. A direct writeto one is answered 200 whether TP stored the value or recomputed it from those
rows and changed nothing, and no re-read separates "stored because nothing
overrode it" from "recomputed back to the same number". The four write methods
therefore raise
ValueErrorbefore any request and name the RoleEffort route,which is deterministic whatever the collection holds.
A
TeamIterationclear can only be known after the write, so it is verifiedafter it. TP cascades a parent's team iteration onto its children, so a child's
explicit
nullis answered 200 whether the field cleared, was discarded, orcleared and was immediately re-acquired from the parent — and an item that should
be unscheduled silently stays scheduled.
clear_team_iterationsends the clearand checks it.
Neither guard reuses the other's path.
No GitHub issue; this change is tracked outside this repository.
Changes
Five commits.
1.
feat!: refuse a direct write to the derived effort roll-upsresources/_derived.pydeclares the set once asASSIGNABLE_DERIVED_FIELDSand holds the refusal (check_derived_fields, andper batch item
check_derived_items). Its module docstring carries the designrecord: why before the request, and why
ValueErrorrather thanReadOnlyViolation.BaseResource.derived_fieldsapplies it oncreate,update,create_manyand
update_many;AssignableResourcecarries the declaration for the sixwork-item managers.
entitiesmirrors it for every spelling of those six collections and of theuntyped Assignable-derived ones, so the generic accessor is not the way round
the typed guard. A walk test fails if a declaring manager is unreachable
through that map — the same shape as the existing ignored-filter walk.
allow_derived=Truesends the write anyway. See The escape hatch is thepoint, not a loophole below.
AssignableEntityandRoleEffortsResourcedocstrings now say where awork item's effort actually lives, pinned by a test so neither can be silently
reverted.
2.
feat: verify that a TeamIteration clear actually clearedclear_team_iteration(id)on the six work-item managers. It isupdate(id, TeamIteration=None, verify=True)and nothing more — the samewrite, the same single narrowed re-read, the same comparison — with the failure
re-raised as the new
TeamIterationCascadeError.TeamIterationCascadeErrorsubclassesVerificationError, so a caller alreadyhandling "the re-read did not show the write" catches it; the distinct type is
for the caller that treats the cascade differently, because the remedy is not a
retry but clearing or detaching the parent.
verify=False, deliberately: an unverified clear cannot be told from afailed one, which is the method's whole reason for existing. A caller wanting
the bare write calls
updatedirectly.3.
docs: note the escape hatch the recorded write fixtures takedocs/testing.md's write-path recording rules describeEffortas the numericfield each update sends and reads back. It is now a refused roll-up, so that
passage says the sends pass
allow_derived=True, and why they may.4.
docs: split observability and time logging into their own filesTwo tracked files were at the 1000-line ceiling the pre-commit and CI gate
enforces —
SPEC.mdat 999, anddocs/USAGE.mdat 992 once the two guards weredocumented — so neither could take another paragraph.
SPEC.md's Observabilitysection moves to
docs/observability.mdanddocs/USAGE.md's Logging time todocs/time.md, both following the precedentSPEC.mdalready sets for itsTesting Approach section: content unchanged, a stub in its place naming the new
file. See Nothing was lost in the extractions below.
5.
docs: state both write contracts in the specification### Derived-field writesnext to the silently-ignored queries it shares itsposture with,
### Unscheduling: the TeamIteration cascadeafter the verifiedwrites it is built from, plus
TeamIterationCascadeErrorin the exceptions listand
allow_derivedin the resource signatures.The escape hatch is the point, not a loophole
An unconditional refusal would have been wrong, and this repository's own
recorded fixtures are what say so.
test_verified_update_returns_the_re_readsends
Efforton a sandbox story withverify=Trueand asserts it on theindependent re-read, with
entity_version is Noneproving it is the re-readand not the echo. So a direct write to a roll-up demonstrably can land.
That is exactly why the guard refuses rather than warns: the same request against
an item that carries
RoleEffortrows is answered with the same success statusand a different outcome, and the caller cannot tell which from the response. The
escape hatch exists for the caller who knows which case they are in. The recorded
write suite is that caller — each entity it writes is created in the same run —
and its four sends now pass
allow_derived=Truewith the reasoning at the callsite. No cassette was re-recorded: the keyword is consumed library-side, so
every request body is byte-identical and the recordings replay untouched.
The unit verified-write tests took the other option and moved their probe field
from
EfforttoNumericPriority, so the library's own unit tests do not opt outof the library's own guard.
The specification states only what those recordings evidence — that such a write
can land, that it can equally be recomputed away, and that the response does not
distinguish them — and attributes the zero-
RoleEffortmechanism as the likelyexplanation rather than established behaviour. Asserting an unproven mechanism
would be the very failure these two guards exist to prevent.
Nothing was lost in the extractions
Each moved block round-trips byte for byte against the revision it came from,
with only the heading level shifted for a standalone file and one provenance
sentence added — checked by rebuilding the original block from the new file and
comparing (87 lines and 104 lines, both identical).
The one cross-reference this breaks —
docs/USAGE.md's own link to thecustom-activity subsection — now points into
docs/time.md. No other filereferenced either section's anchors, and the six
__all__names theobservability text mentions appear in
docs/observability.mdwith the sameoccurrence counts they had in
SPEC.md.SPEC.mdis now 981 lines anddocs/USAGE.md892, leaving 19 and 108 ofheadroom.
resources/base.pyis 989, leaving 11 — the per-item bulk loop livesin
_derived.pypartly to keep it there.Testing
Run against the head commit, each as its own command:
uv run ruff check .uv run ruff format --check .uv run mypy --strict srcuv run pytest -q --covuv run pre-commit run --all-files --hook-stage pre-commituv run pre-commit run --all-files --hook-stage pre-pushuv run python scripts/check_large_files.pyuv run python scripts/check_internal_refs.pyuv run python scripts/check_todos.pyuv run python scripts/check_model_coverage.pyEvery file this touches is at 100% line and branch coverage
(
_derived.py,assignables.py,exceptions.py,role_efforts.py).55 tests added. The suite went from 1450 to 1505 with no test removed.
What the new tests actually pin
client over an
httpx.MockTransportthat raises if reached, and asserts therequest log is empty. READWRITE deliberately — a READONLY client would raise
ReadOnlyViolationfirst and prove nothing about this guard.test asserts
clear_team_iteration's request sequence — method, path, everyquery parameter, and body bytes — is identical to
update(..., TeamIteration=None, verify=True)'s. A bespoke write-then-readinside the method would look the same from outside until the verified-write
rules changed underneath it; this fails if they diverge.
write echo still carries the iteration, and in the failing test the echo says
the clear worked — so returning the echo fails in one direction and misses the
cascade in the other.
Progress,TimeSpentand
TimeRemainare not guarded and do reach the wire.tests/test_exceptions.py's existing walk over every library error caught thenew exception's absence from its pickle table, which is a fair signal about the
suite.
Honest note on mutation testing
I did not construct a meaningful mutation test. Against the true base revision
neither facility exists, so every new test fails with an
AttributeErroror amissing refusal — that establishes the tests exercise new code and nothing more.
What they establish beyond that is the identity assertion and the empty-log
assertions above, both of which fail against a plausible wrong implementation.
Stating it plainly rather than dressing it up.
Context
naming one of the three roll-ups now raises
ValueErrorbefore sending. Writethe role's own row through
client.role_efforts— the error message carriesthe query that finds it — or pass
allow_derived=True.RoleEffort's owneffort fields are stored as written and are unaffected.
allow_derivedshadows a wire field literally namedAllowDerivedoncreateandupdate, the same pre-existing hazardverifyhas onupdate.No such TargetProcess field is known.
numbers (
Progress,TimeSpent,TimeRemain,LeadTime,CycleTime) andnone is declared: each would need its own route named in its own message and
the same live evidence an ignored-filter entry is added on. Widening a guard
without evidence is the same error as asserting an unproven mechanism.
clear_team_iterationis single-entity, so a caller unscheduling a batch drops to
update_many(..., verify=True)and gets a bareVerificationErrorrather thanthe cascade error. The generic
entitiesaccessor carries no work-itembehaviour at all — no
clear_team_iterationand noadvance_state— which isconsistent with what was already there. A recorded live cassette for the
cascade is still outstanding; it needs a parent/child pair built on the sandbox
first, and is tracked outside this repository.
SPEC.md's Public API Surface and the exceptions it defines are back in sync:the new exception is listed, and the surface's remaining gaps are unchanged
from the base revision (verified by running the same check against both).
Checklist
uv run ruff check .anduv run ruff format --check .passuv run mypy --strict srcpassesuv run pytest -qpasses and coverage stays at or above 90% — 99%uv run pre-commit run --all-files --hook-stage pre-commitpassesuv run pre-commit run --all-files --hook-stage pre-pushpassesTODO/FIXME/HACK/XXXmarkers name an issue — none added🤖 Generated with Claude Code