docs: lock pillar 4 design (release management) - #18
Conversation
Captures the release-management design from #10, grounded against weyucou/ibuki-backend@main (9ec13c7): a new backend/releases/ Django app, tenant-only scoping, locked specs.SpecVersion rows as the bundled unit, and the lock step (ibuki.lock) as pillar-4 scope and the critical path. Locks the data model (Release + ReleaseItem), manifest hashing, the three versioning schemes, select_for_update(nowait=True) concurrency, and application-layer immutability mirroring the shipped ProposalEvent and SpecVersion patterns. Adds the four-child split for ibuki-backend and the AC-to-test map. Three items stay open: DECISION 1 (accepted-payload source for ibuki.lock, blocks child 4 only), DECISION 3 (date-scheme exhaustion), and a cut-window watermark gap where a late-committing lock can be missed by both the current and the next release.
|
✅ CI green — Slack notification sent. |
ellen-goc
left a comment
There was a problem hiding this comment.
PR Review — #18 vs issue #10 (pillar 4, release management)
Scope check: this PR locks the design only; it correctly does not claim Closes #10. Issue #10 is complexity:complex (an epic) and stays open until the four ibuki-backend children are filed and merged. Reviewed accordingly: the deliverable is the design-locking portion, not the twelve implementation ACs.
Grounding verification — passed
The PR's central claim is that every backend fact was read from weyucou/ibuki-backend@main (9ec13c7) rather than from memory. I re-read the same files at that SHA. Every claim holds:
| Claim in the doc | Verified |
|---|---|
No Project model; tenant is the only scope |
✅ tenants/models.py ships Tenant + ApiToken only |
ProposalState has no locked member; accepted is terminal |
✅ proposals/states.py — ACCEPTED maps to frozenset() |
Proposal persists no payload |
✅ proposals/models.py — tenant, feature_id, supersedes, scope_in, scope_in_normalised, state, round, timestamps |
SpecVersion.locked_at; Spec→SpecVersion is CASCADE; retention is pillar-4 scope |
✅ specs/models.py docstring and spec FK comment say exactly that |
SpecVersion.lock() raises SpecLockedError on an already-locked row |
✅ — so ALREADY_LOCKED is a real path, not a speculative one |
Spec.add_version() wraps read-then-insert, guarded by unique_spec_version |
✅ |
lock / cut_release stubs labelled "(pillar 4)" |
✅ mcp_server/tools.py build_mcp() |
ORM-touching MCP tools must be async (SynchronousOnlyOperation) |
✅ recorded in the get_spec docstring |
context.py binds tenant only; no current_token() |
✅ — auth.py resolves ApiToken then passes only api_token.tenant into tenant_context() |
CheckErrorCode is a StrEnum with SCREAMING values |
✅ conformance/schemas.py:63 |
spec_payload returns {found, spec} — absent is well-formed |
✅ specs/loader.py ABSENT_SPEC_PAYLOAD |
tenants is at 0003_tenant_check_token_budget, so 0004 is next |
✅ |
ibuki-backend#18 owns submit/respond; the repo is at #31 |
✅ #18 = "MCP wiring: ibuki.submit/respond/list_escalated (pillar 3, child 4/4)"; #31 is the last merged PR |
All twelve ACs in #10 are represented in What this pillar delivers and mapped in the AC→test table. README index entry resolves, in-doc anchors (#what-grounding-changed, #open-items) match their headings. CI green (schema validation + gitleaks). Structure follows the pillar-2 (#13) precedent.
Findings
1. get_release(release_id) has no stated tenant scoping — the one IDOR-shaped gap in an otherwise closed surface.
The MCP table specifies { release_id } → { found, release } and the doc states no tool accepts a tenant argument, but it never says the lookup is filtered by current_tenant(), nor which code applies when a caller passes another tenant's release_id. Both candidate codes exist in ReleaseErrorCode (RELEASE_NOT_FOUND, TENANT_MISMATCH) and the TENANT_MISMATCH row in the lock table is scoped to proposals only. This is exactly the case the shipped code treats explicitly — _get_spec_by_id() checks spec.tenant_id != tenant.pk, emits a tool.tenant_mismatch warning, and returns a distinct payload, precisely because an id is global while a name lookup is tenant-scoped in the query.
Child 4 implements from this doc. Suggest locking it here: get_release filters on tenant=current_tenant() and returns {"found": false, "release": null} for a release it does not own (not-found rather than TENANT_MISMATCH, so the response does not confirm the id exists), with a tool.tenant_mismatch warning log matching the get_spec precedent. The AC→test map has test_list_releases_excludes_other_tenant but no get_release equivalent — worth a matching row.
2. The locked cut_release flow embeds the query that open item 3 says is defective, and the locked data model has nowhere to put the fix.
Step 3 of cut_release flow specifies locked_at__gt=predecessor.cut_at with no inline caveat; open item 3, ~130 lines later, explains that this exact predicate permanently drops any lock that stamps locked_at before a cut but commits after it. A child-3 author reading the flow section as the locked contract will implement the known defect.
The sequencing consequence is sharper: the recommended fix stores max(item.locked_at) as a watermark, but the locked Release model has no such field — it carries release_id, tenant, version, scheme, cut_at, cut_by, predecessor, manifest_hash, manifest_json. Child 1 is "unblocked today, can start immediately" and owns 0001_initial, so as written it will ship a migration that child 3 must then amend. Either resolve open item 3 before child 1 files its migration, or add the nullable watermark column to the locked data model now and let child 3 populate it. At minimum, mark step 3 as provisional and cross-link open item 3 from it.
3. The tenant row as the cut mutex has a false-positive path.
Tenant.objects.select_for_update(nowait=True) fails on any concurrent row lock, not just another cut. A Django admin save of judge_model or check_token_budget — both shipped, both edited through admin, which is also how this design sets versioning_scheme — takes an exclusive lock on the same row and would surface to a concurrent cut as CONCURRENT_CUT, an error whose contract says "another cut is in flight". Worth either naming the coupling in the concurrency rationale, or keying the mutex on something cut-specific (pg_advisory_xact_lock on the tenant id, or a lock on the predecessor Release row). The UniqueConstraint(tenant, version) backstop is unaffected either way.
4. The DB-grants deferral cites a different issue than the shipped code does.
The doc points twice at ibuki-backend#14 and calls it "the same deferral proposals/models.py already documents". Both proposals/models.py and specs/models.py actually defer to #4 ("AWS deployment: Fargate + Postgres"); #14 is its 2/2 split ("apply stacks, migrate, smoke-test"). #14 is a defensible owner for the grants, but "same deferral" overstates it — either cite #4 or note that #14 is the split that inherits it.
Nits
list_releasespages oncut_atwith no tiebreaker. Per-tenant cuts are serialized by the row lock so ties are near-impossible, but(cut_at, release_id)as the cursor costs nothing and removes the class of bug.VersioningSchemeinreleases/versioning.pyimported bytenants/models.pyis import-cycle-safe as the doc argues, but it does invert app layering —releasesdepends ontenants, not the reverse. Since child 2 owns bothversioning.pyandtenants/0004, siting the enum intenants/(or a shared module) would keep the dependency one-directional at no cost.- The pillar-2 doc's abridged data model still lists
Tenant(..., versioning_scheme, ...)and aProjectmodel, both of which this PR's grounding contradicts. Not this PR's defect, but pillar 2's doc is now stale on two points a reader may cross-reference.
Definition of Done
Applying the done checklist to the design-lock deliverable this PR actually carries:
| Item | Result |
|---|---|
| Acceptance criteria met | ✅ for the design-lock scope — all 12 ACs of #10 are represented and mapped to named tests; implementation ACs remain open in the children, as the PR states |
| Unit tests cover new logic | n/a — docs only, no code in this repo |
| CI green | ✅ schema validation + gitleaks both passed on c5384a6 |
| Documentation updated | ✅ new pillar doc + README index entry |
| Code review approved | pending — this review |
| No known regressions | ✅ additive, 308 insertions, 0 deletions |
| Security checklist | n/a for a docs PR; note finding 1, which is a security-relevant gap in the designed surface rather than in shipped code |
Epic gate: #10 is complexity:complex and must not enter in-progress or go to askcc develop — it is split into the four complexity:medium children first, which this PR's child split defines but does not file. That remains the next action.
Verdict
Approve in substance — the grounding is real and I could not find a claim in it that the shipped tree contradicts, which is not the usual outcome for a re-grounded design doc. Recommend addressing 1 and 2 before merge (both are additive: one contract line plus a test-map row, and one field plus a cross-link), and 3/4 before the children are filed. None of the four makes the doc wrong today; all four get more expensive once child issues are cut from it.
…ck_watermark (#10)
|
Review response — findings 1 and 2 addressed in
Findings 3 (cut-mutex false positive) and 4 (DB-grants deferral citation) plus the nits remain open for the child-filing step, per the review's own sequencing. With 1 and 2 landed this is mergeable from my side — leaving the merge to a human reviewer per policy. |
|
✅ CI green — Slack notification sent. |
ellen-goc
left a comment
There was a problem hiding this comment.
PR review — #18 against issue #10
Verdict: comment (blocking concern on one locked design element). Scope, grounding and AC coverage all hold up; one recommended fix baked into the locked data model does not close the defect it is aimed at.
Reviewed as a design-lock PR, not a delivery of #10 — the PR correctly declines Closes #10, and #10 is complexity:complex (an epic), which per the Definition of Done never enters in-progress itself and closes only when its children close.
Verification performed
Every backend claim in the doc was re-read from weyucou/ibuki-backend@origin/main (9ec13c7, confirmed as current HEAD). Confirmed:
| Claim | Evidence |
|---|---|
No Project model; Spec unique on (tenant, name) |
specs/models.py — unique_tenant_spec_name |
ProposalState has no locked; terminal accept is accepted |
proposals/states.py |
specs/models.py names weyucou/ibuki#10 as the consumer, and labels retention past spec deletion pillar-4 scope |
module docstring + SpecVersion.spec CASCADE comment |
ibuki.lock / ibuki.cut_release stubs labelled "(pillar 4)" |
mcp_server/tools.py build_mcp() |
Proposal persists no payload |
proposals/models.py — tenant, feature_id, supersedes, scope_in, scope_in_normalised, state, round, timestamps only |
Spec.add_version() + SpecVersion.lock() ship; lock() raises SpecLockedError when already locked |
specs/models.py |
ProposalEvent append-only + DB-grant deferral documented |
proposals/models.py docstring |
Tenant.judge_model / Tenant.check_token_budget, neither with an MCP setter; tenants at 0003_tenant_check_token_budget |
tenants/models.py, tenants/migrations/ |
CheckErrorCode is a StrEnum with SCREAMING values |
conformance/schemas.py:63 |
spec_payload absent-is-well-formed precedent |
specs/loader.py |
auth.py resolves ApiToken but context.py binds tenant only |
mcp_server/auth.py, mcp_server/context.py |
hypothesis is a dev dependency; Postgres 17 in CI |
pyproject.toml:47, .github/workflows/ci.yml:64 |
Doc hygiene: README link resolves, in-doc anchors #what-grounding-changed / #open-items match their headings, section structure follows the pillar-2 precedent. CI green (schema validation + gitleaks).
All 12 acceptance criteria in #10 are addressed and traced in the testing-strategy table, plus two rows beyond them (cross-tenant release_id, hash order-independence).
1. Blocking — the recommended watermark fix does not close the defect (open item 3, and the locked data model)
The defect is stated correctly, but max(item.locked_at) as the next cut's lower bound only rescues the case where the late-committing lock also carries the latest locked_at. That is the opposite of the case that produces the bug:
t0— lock A stampslocked_at = t0, transaction still opent1— lock B stampslocked_at = t1, commitst2— cut #1 runs. Sees B only.lock_watermark = t1,cut_at = t2t3— lock A commits; row is now visible withlocked_at = t0- cut #2 filters
locked_at > t1→ A (t0) is still excluded, permanently
A lock that commits late almost always stamped earlier than the locks that overtook it, so the watermark leaves the common interleave unfixed while adding a column.
The robust form is a set difference rather than a time window — bundle every locked SpecVersion for the tenant that no ReleaseItem references yet:
SpecVersion.objects.select_related("spec").filter(
spec__tenant=tenant,
locked_at__isnull=False,
release_items__isnull=True, # needs related_name on ReleaseItem.spec_version
).order_by("locked_at", "pk")Correct under any commit order, and UniqueConstraint(release, spec_version) is already the backstop. Note the tempting alternative — a released_in / consumed flag on SpecVersion — is not available: SpecVersion.save() rejects every write to a locked row, so the anti-join has to live on the releases side.
This matters now rather than at child 3, because the doc locks lock_watermark into the data model with child 1 shipping it in 0001_initial. Either drop the column from the locked model and leave the selection strategy open for child 3, or switch the recommendation to the anti-join and drop the column in the same edit. Ordering, cut_at semantics and the manifest contract are unaffected either way.
2. Two grounded claims that do not match the tree
Proposalis not unique on(tenant, feature_id). "What grounding changed" asserts it, butproposals/models.pyMetacarries onlyorderingand theproposals_scope_norm_ginindex — there is noUniqueConstraintanywhere in the app. The conclusion it supports (tenant-only scoping, noProject) is unaffected; the supporting claim should be corrected, since the section's value is that every line in it was verified.- The cross-tenant
get_releasebehaviour does not match the_get_spec_by_id()precedent. The shipped handler returns{"error": "tenant_mismatch"}for another tenant'sspec_id; the doc specifies{"found": false, "release": null}for another tenant'srelease_idand calls it a match. Only thetool.tenant_mismatchwarning log matches — the response shape is the opposite. The doc's choice is the better one (it does not confirm the id exists), so state it as a deliberate divergence and file a follow-up to alignget_spec, rather than describing it as precedent-following. As written, the MCP surface ships two different answers to the same question.
3. Structured-error translation needs a placement note (child 3)
Both nowait=True and UniqueConstraint(tenant, version) surface as exceptions that abort the Postgres transaction. Catching them inside transaction.atomic() and returning CONCURRENT_CUT / VERSION_TAKEN raises TransactionManagementError instead — the translation has to happen outside the block. Worth one line, since the doc names both translations without saying where they happen. Related: the nowait failure should be discriminated on pgcode 55P03 (lock_not_available) rather than a bare DatabaseError, or a genuine database outage gets reported to the caller as a concurrent cut.
4. Error codes without a test row
ReleaseErrorCode is locked with nine values, but the testing-strategy table names tests only for the AC-derived ones. ALREADY_LOCKED, PROPOSAL_NOT_ACCEPTED, TENANT_MISMATCH (lock path), VERSION_TAKEN and VERSION_EXHAUSTED have no row. Add a row each, or a line assigning them to a child — an enumerated code with no named test tends to arrive as an unstructured 500.
Nits
tenants/models.pyimportingVersioningSchemefromreleases/versioning.pyinverts app layering: a base app gains a dependency on a pillar app. The doc's cycle argument is sound and it will work, butbackend/commonsalready exists as the shared home and would avoid the inversion.list_releaseskeyset pagination oncut_atalone: no unique constraint backs that column. The tenant row lock makes a collision unlikely in practice, but a(cut_at, release_id)cursor removes the skip/duplicate edge case for free.- The doc says ibuki-backend "is at #31"; it is now at #32. Immaterial — the children take the next free numbers.
Summary
The grounding pass is thorough and holds on every point I could check against the shipped tree, and the doc is a materially better artifact than the epic body it replaces. Item 1 is the one thing worth fixing before merge, because it is locked into a migration child 1 ships. Items 2–4 are edits to the doc's own text. Once item 1 is resolved, this is ready to merge and the four children can be filed.
Summary
Locks the pillar-4 design into
docs/pillars/04-release-management.md, following the pillar-2 (#13) precedent, and adds the README index entry. Implementation lands inweyucou/ibuki-backendas a newbackend/releases/app — no code in this repo.The design was re-verified against
weyucou/ibuki-backend@main(9ec13c7) —tenants/models.py,specs/models.py,proposals/models.py,proposals/states.py,mcp_server/{tools,context,auth}.py,conformance/schemas.py,pyproject.toml,.github/workflows/ci.yml. Three grounded facts shape it:Projectmodel —tenantis the only scope.specs.SpecVersion, not aProposal.ProposalStatehas nolockedmember, andspecs/models.pynames this issue as the consumer oflocked_at.ibuki.lockstub is labelled "(pillar 4)" inmcp_server/tools.py, and nothing in pillar 3 writes aSpecVersion.The doc locks:
Release+ReleaseItem, withReleaseItem.spec_version = PROTECTclosing the retention gapspecs/models.pyflags as pillar-4 scope.manifest_jsonso verification re-hashes stored bytes.semver/date/sequentialas pure functions, configured by a newTenant.versioning_schemefield (Django admin, matchingjudge_modelandcheck_token_budget— no MCP setter).select_for_update(nowait=True)on the tenant row, withUniqueConstraint(tenant, version)as backstop.ProposalEventappend-only andSpecVersionlock patterns; DB grants stay deferred to ibuki-backend#14.lock/cut_release/get_release/list_releases,ReleaseErrorCode, andcut_byderived server-side from the authenticatedApiToken.ibuki-backend, plus the AC→test map.Two design points are resolved beyond the epic text, both grounded in the shipped code:
release_idis a pre-generated UUID. The manifest contains its ownrelease_idand is hashed before insert, so a DB-assigned id would force a follow-upUPDATE— which the append-only guard rejects. A UUID also keeps one tenant's cut volume from being inferred off a shared integer sequence.ALREADY_LOCKEDis added to the error codes.SpecVersion.lock()raisesSpecLockedErroron an already-locked row, so the path exists whether or not it is named; unnamed it would surface as an internal error.Open items carried forward
proposals.Proposalpersists no payload, verified inproposals/models.py, so an accepted proposal cannot currently be turned back into spec content. Recommendation (a): ibuki-backend#18 persists the accepted payload.-Z. Recommendation: fail withVERSION_EXHAUSTED.locked_at > predecessor.cut_atcan drop a lock that stampslocked_atbefore a cut but commits after it: the cut's query cannot see it, and the next cut's filter excludes it. Reachable under Postgres' default READ COMMITTED whenever a lock and a cut overlap. The doc records a recommended fix and asks for aTransactionTestCasereproducing the interleave.DECISION 2 (
name=override strictness) needs no separate answer — the issue's own acceptance criteria mandate validating a suppliednameagainst the tenant's scheme and rejecting a mismatch withVERSION_MISMATCH.Closes #10is not appropriate yet — #10 stays open until the fouribuki-backendchildren are filed and merged. This PR addresses the design-locking portion.Refs #10, ibuki-backend#18, ibuki-backend#14.
Test plan
pre-commit run --all-filespasses (JSON Schema metaschema, reference template, gitleaks)origin/mainat9ec13c7— no claims from memorydocs/pillars/04-release-management.mdresolves; in-doc anchors (#what-grounding-changed,#open-items) match their headings