Repository navigation
OpenConceptLab/ocl_online#532 | Capability policy backend, and settings to load extra apps and URLconfs - #963
Conversation
…gs to load extra apps and URLconfs CAPABILITY_POLICY names the class that decides and records the use of capabilities, as EMAIL_BACKEND does for mail. The default, GroupLimitPolicy, is the per-user override and group limit logic that lived on UserProfile, moved unchanged. UserProfile's three capability methods now delegate to it. - Usage counts in windows: UsageCounter and UsageEvent get window_start. Null is the one window that never ends, which every existing row and the default policy use. consume() locks the user's row first, so a user's requests serialize even when they count in different windows. - A failed $match reverses its own usage event, which lowers the counter it counted in and records reversed_units on the event. - Refusals are UsageRefused, whose detail is the 403 body as the policy built it. Usage reports send every field the policy's get_usage() returns. Under the default policy every response is unchanged. - OCL_EXTRA_APPS and OCL_EXTRA_URLCONFS, comma-separated in the environment and empty by default, add Django apps and URLconfs to a build. Extra URLconfs come after core's own routes. - A contract test runs a fake policy through every call site. Refs OpenConceptLab/ocl_online#352 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RK9RzEWK5NKU6Wj9WSYGFw
…EY UPDATE A plain FOR UPDATE blocks the foreign-key check of another transaction that inserted a counter for the same user, such as a staff refund creating the current window's counter, while consume() waits on that counter's unique entry: a deadlock. FOR NO KEY UPDATE still serializes a user's consumes and lets the foreign-key check through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RK9RzEWK5NKU6Wj9WSYGFw
…usal body Nothing reads its limit, used, requested or capability_name attributes since the body moved into detail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RK9RzEWK5NKU6Wj9WSYGFw
Django drops a column's default once AddField has filled existing rows, so code that predates the column, still serving while a deploy migrates, would fail every usage event insert with a not-null violation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RK9RzEWK5NKU6Wj9WSYGFw
|
Codex review, pass 1 (codex-cli 0.164.0, commit 03e82de) Verdict: 1 finding(s).
findings.json{
"reviewer": "codex-cli 0.164.0",
"perspective": "defects",
"mode": "full",
"commit": "03e82de8",
"verdict": "findings",
"prior": [],
"findings": [
{
"id": "F1.1",
"severity": "P2",
"tag": null,
"file": "core/capabilities/policy.py",
"line": 119,
"claim": "The new user-row lock can deadlock a first consume against a staff refund.",
"scenario": "When the current window has no counter, a concurrent staff refund can insert it before consume locks the user. Consume then waits on the refund's uncommitted unique entry; the refund's deferred user foreign-key check waits on consume's FOR UPDATE lock. PostgreSQL aborts one transaction, producing an uncaught 500. This follows from static lock-order analysis using PostgreSQL's [uniqueness checks](https://www.postgresql.org/docs/14/index-unique-checks.html) and [foreign-key locking](https://github.com/postgres/postgres/blob/REL_14_STABLE/src/backend/utils/adt/ri_triggers.c).",
"action": "Use select_for_update(no_key=True) for the user lock, which still serializes consumes while permitting foreign-key checks under PostgreSQL's [lock compatibility rules](https://www.postgresql.org/docs/14/explicit-locking.html#LOCKING-ROWS), or make refund acquire the user lock first. Add a PostgreSQL concurrency regression test for an absent counter.",
"lines_delta": null,
"confidence": "high"
}
]
}Response: F1.1 fixed in 3765046. consume() locks the user row FOR NO KEY UPDATE, which still serializes a user's consumes and lets the foreign-key check through. Pinned by test_consume_locks_the_account_without_blocking_foreign_key_checks. |
|
Codex review, pass 2 (codex-cli 0.164.0, commit 3765046) Verdict: clean. Earlier findings: F1.1 resolved. findings.json{
"reviewer": "codex-cli 0.164.0",
"perspective": "defects",
"mode": "verify",
"commit": "3765046e",
"verdict": "clean",
"prior": [
{
"id": "F1.1",
"status": "resolved"
}
],
"findings": []
} |
…ackend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
…al owner get_limit, get_usage, consume and describe, and UserProfile's three capability methods, take an optional `owner`: the user or organization whose content a use is for. GroupLimitPolicy ignores it, so nothing changes under the default. GroupLimitPolicy.consume() hands the owner to get_limit(), so a policy that replaces only get_limit() enforces the limit it reports. No call site passes an owner yet, and no request can name one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
|
Codex review, pass 1 (codex-cli 0.166.0, commit e9a93d7): clean. The full pass is on OpenConceptLab/ocl_online#620. |
…eutral name for a refusal's flag From the review of the owner argument: - CapabilityPolicy says what `owner` covers. Windows, counters, events and reversals stay per user, and a policy that decides by `owner` first checks that `user` may act for it. - The test policy keeps blocked and unlimited values instead of replacing them. - UsageRefused's flag is now `no_access`. Error codes and response bodies don't change. - Two describe() assertions go: the contract test already covers them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
|
Simplification and adversarial reviews (commit e9a93d7). The full record is on OpenConceptLab/ocl_online#620.
The branch brings in |
snyaggarwal
left a comment
There was a problem hiding this comment.
Verdict: Request changes
Reviewed together with OpenConceptLab/ocl_online_api#41, focusing on object ownership, existing patterns, readability, module placement, and single sources of truth.
- P1: the new public default still computes and enforces deployment-specific access/limit decisions. The public side should expose neutral metering/checkpoints and remain fully usable by itself; the replacement layer should supply finished decisions.
- P2: the policy is presented as the owner of usage recording, but the staff correction path still reaches through it into UsageCounter.
- P2: CapabilityPolicy is only a convention, so the paired PR has to duplicate its required-method list to validate implementations.
- P3: the policy loader is placed in the catch-all common utilities module instead of beside the policy abstraction.
No additional nits beyond the inline comments.
| logger = logging.getLogger('oclapi') | ||
|
|
||
|
|
||
| class CapabilityPolicy: |
There was a problem hiding this comment.
[P2] Make the policy contract enforceable where it is defined. Using ABC plus abstractmethod (and a public Django system check/loader beside this class) would let Python validate implementations. As written, missing overrides fail only at request time unless the private extension happens to be installed, and the paired PR has to maintain a second REQUIRED_METHODS list that can drift from this class.
|
|
||
| UsageCounter.refund(user, capability_id, units=units) | ||
| policy = get_capability_policy() | ||
| UsageCounter.refund(user, capability_id, units=units, window_start=policy.get_window_start(user, capability_id)) |
There was a problem hiding this comment.
[P2] Route corrections through the policy abstraction. CapabilityPolicy says it decides and records usage, but this view asks it for a window and then mutates UsageCounter directly. Any replacement that stores usage differently, applies correction rules, or does not use UsageCounter cannot implement the advertised interface. Add a policy method for this staff correction and let GroupLimitPolicy own the UsageCounter call.
| return getattr(mod, klass)() | ||
|
|
||
|
|
||
| def get_capability_policy(): |
There was a problem hiding this comment.
[P3] Keep the loader with the abstraction it constructs. get_capability_policy is capability-domain wiring, not a general utility; putting it in core.capabilities.policy (or a small core.capabilities.loader module) makes the dependency discoverable and avoids growing common.utils as a service locator.
…forced where it's defined - CapabilityPolicy is an ABC. A system check beside it fails start-up if CAPABILITY_POLICY doesn't import, isn't a subclass, or leaves a method unimplemented (capabilities.E001, E002, E003). - Staff corrections go through a new policy method, refund(), so the refund view no longer touches UsageCounter. GroupLimitPolicy makes that call. - get_capability_policy moves from core.common.utils to core.capabilities.policy. - get_window_start moves to GroupLimitPolicy, the only class that still reads it, and its refusal hook goes: nothing overrides it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
…natures, and abstract methods still raise From the adversarial review of the interface change: - capabilities.E004: a method that can't take the arguments it's called with, or an __init__ that needs one, fails start-up. It reads signatures only, and the policy isn't made. - CapabilityPolicy's abstract methods raise NotImplementedError again, so a subclass's super().refund() or super().reverse() can't quietly do nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
…by name The check takes optional arguments by name, as every other call site passes them. A policy with keyword-only units passed it, then failed in check_and_consume_capability, the one caller that passed units by position (Codex check of c6ae07b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
snyaggarwal
left a comment
There was a problem hiding this comment.
Second-pass verdict: Request changes
I read all new PR replies and the full updated discussion on OpenConceptLab/ocl_online#620 and #630. The prior ABC/check, refund ownership, and loader-placement findings are resolved. Per the explicit decision on #620, removal of GroupLimitPolicy is deferred to #630 and is not re-raised here.
One P2 remains: a single consume response uses two independently constructed policy objects, and capability-list serialization constructs another object per item. That defeats the instance lifecycle provided by the abstraction and can mix snapshots in one response for a stateful replacement policy. Reuse one policy instance for each logical operation/response.
Current CI is green: Pylint, Tests, and Snyk pass.
| 'capability': capability_name, | ||
| 'limit': request.user.get_capability_limit(capability_id), | ||
| 'used': request.user.get_capability_usage(capability_id), | ||
| **get_capability_policy().describe(request.user, capability_id), |
There was a problem hiding this comment.
[P2] Reuse the policy instance that performed the consume when describing its result. request.user.check_and_consume_capability() constructs a policy internally, then this line constructs a second one; a stateful replacement that caches a consistent snapshot per instance can therefore record against one snapshot and report another in the same response. Instantiate once in this view and call policy.consume(...) followed by policy.describe(...). Apply the same lifecycle to CapabilitySerializer, which currently constructs one policy per capability, so a list response is one coherent snapshot and avoids repeated setup/queries.
…n or response A consume response, a $match and its reversal, and a list of capabilities each made several policies, so a policy keeping a snapshot per instance could record against one and report another. No response changes under the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq
snyaggarwal
left a comment
There was a problem hiding this comment.
Third-pass verdict: Approve — recommend merge.
The remaining policy-instance P2 is resolved across consume responses, $match/reversal, refunds, and capability-list serialization, with focused regression tests. The prior ABC/check, policy-owned refund, and loader-placement findings remain resolved. The temporary public default is intentionally deferred to OpenConceptLab/ocl_online#630 per the agreed deployment order.
Current Pylint, Tests, and Snyk checks pass.
snyaggarwal
left a comment
There was a problem hiding this comment.
Third-pass verdict: Approve — recommend merge.
The remaining policy-instance P2 is resolved across consume responses, $match/reversal, refunds, and capability-list serialization, with focused regression tests. The prior ABC/check, policy-owned refund, and loader-placement findings remain resolved. The temporary public default is intentionally deferred to OpenConceptLab/ocl_online#630 per the agreed deployment order.
Current Pylint, Tests, and Snyk checks pass.
|
The changes made in response to the reviews, approved at f4fa8c5 and merged as squash 42bd393. Each pass is in full on OpenConceptLab/ocl_online#620. Changed
Not changed here: moving the default policy's logic out of this repo is OpenConceptLab/ocl_online#630, in the order agreed on OpenConceptLab/ocl_online#620. Tests: Review
|
OpenConceptLab/ocl_online#620 and OpenConceptLab/ocl_online#532, with the public metering parts of OpenConceptLab/ocl_online#352.
What this does
CAPABILITY_POLICYnames the class that decides and records the use of capabilities, asEMAIL_BACKENDdoes for mail.CapabilityPolicyis its abstract base, andget_capability_policy()beside it returns one. The default,GroupLimitPolicy, is the per-user override and group limit logic that lived onUserProfile, moved unchanged.UserProfile.get_capability_limit,get_capability_usageandcheck_and_consume_capabilitynow delegate to it, so their callers don't change.core/capabilities/checks.pyfailsmigrateandmanage.py checkon aCAPABILITY_POLICYthat doesn't import (capabilities.E001), isn't a subclass (E002), leaves a method unimplemented (E003), or has a method that can't take the arguments it's called with (E004). It reads signatures and doesn't make the policy. A process that starts without running either command doesn't run the check.$matchand its reversal, and a list of capabilities each use one policy instance, so a policy that keeps a snapshot per instance stays coherent within a response.UsageCounterandUsageEventgetwindow_start.nullis the one window that never ends; every existing row and the default policy use it. A subclass ofGroupLimitPolicycan count in windows by overridingget_window_start(). A counter is unique per (user, capability, window), with a second, partial constraint because PostgreSQL 14 treats nulls as distinct. Idempotency keys stay unique per (user, capability) across windows.consume()locks the user's row before the counter, so a user's requests serialize even when they count in different windows.$matchreverses its own usage event (UsageEvent.reverse), which lowers the counter that event counted in and recordsreversed_units. It never takes back more than the event recorded. The staff refund (POST /capabilities/refund/) stays a counter correction, made by the policy'srefund(), in its current window.UsageRefused, whosedetailis the 403 body.$matchandPOST /capabilities/consume/send it as built, adding only their own sentence asdetailwhen the policy gave none.get_limit,get_usage,consumeanddescribe, andUserProfile's three methods, take an optionalowner: the user or organization whose content a use is for. The default policy ignores it. No call site passes one yet, and no request can set one. Windows, counters, events and reversals stay per user. A policy that decides byownerfirst checks thatusermay act for it.GET /user/?includeCapabilities=trueand the consume and refund responses send every field the policy'sget_usage()returns.OCL_EXTRA_APPSandOCL_EXTRA_URLCONFS, comma-separated dotted paths from the environment, empty by default, add Django apps and URLconfs to a build. Extra URLconfs are included after core's own routes, so those keep precedence.used,unitsandreversed_unitsareBigIntegerField.Behaviour under the default
No API response changes: the same limits, the same 403 bodies (
detail,error_code,limit,used, with the same sentences and codes), and the sameused. The migration gives existing rowswindow_start = nulland keeps everyused. The differences are the extra row lock inconsume(),reversed_unitsrecorded when a$matchsearch fails, and the Swagger schema typing the capabilitylimitandusedas integers.Tests
$matchintegration tests that patched the moved methods now patchGroupLimitPolicy, with every response assertion kept, and the users test that patchedget_capability_usagepatchesGroupLimitPolicy.get_usage.CapabilityPolicyContractTestruns a fake policy, set throughCAPABILITY_POLICY, through every call site: theUserProfiledelegates, the user's capabilities, consume (success and refusal),$match(refusal, a failed search that's reversed, a search that isn't), and the staff refund, which leaves the counter to the policy. It also checks that each operation or response makes one policy.CapabilityPolicyInterfaceTest,CapabilityPolicyCheckTestandCallSitesFitTheCheckTest: a policy missing a method can't be made, each of the check's errors, signatures that do and don't fit, and a policy the check passes can be called.GroupLimitPolicyWindowTest: each window counts from 0, a key replays in a later window, a reversal lowers the window its units counted in, and a reversal never takes back more than the event or the counter holds.GroupLimitPolicyDefaultWindowTest: the default counts in the window that never ends;consume()locks the user rowFOR NO KEY UPDATE; an event inserted without the new columns, as the previous release does while a deploy migrates, gets their defaults.ExtraURLConfsTest: an extra URLconf is included after core's routes.GroupLimitPolicyOwnerTest: under the default, an owner changes no limit, usage or charge.ReplacedPolicyOwnerTest: a policy that replacesget_limitgets the owner, and an inheritedconsume()enforces that limit. The contract test checks the owner reaches the policy, and that a consume request can't name one.Run locally in an isolated compose stack on f4fa8c5, as CI runs them (
TEST_ES=off,--exclude-tag es --parallel=4 --keepdb,PYTHONHASHSEED=2): 3,048 tests OK.pylint -j0 core/(pylint 4.0.9): no messages. PR CI runs the ES-tagged tests as well.Review
Reviewed before opening, with Codex (codex-cli 0.164.0, xhigh) and two fresh Claude reviews. Each Codex pass from before the owner argument is posted below.
FOR UPDATEcould deadlock a first consume against a staff refund creating the same counter. Fixed in 3765046 (FOR NO KEY UPDATE).CapabilityExceeded's unread attributes, applied in 777c70a. Codex check of that commit: clean.reversed_unitslost its database default after the migration, so the previous release would fail every usage event insert during a rolling deploy. Fixed in d7cff42 (db_default=0). Codex check of that commit: clean. One P2 was accepted: the int-to-bigint rewrite locks two small tables for milliseconds. Six P3s were recorded, not fixed: a rare deadlock between a consume and deleting that same user (on master the same race ends in a 500), and contract edges for other policies and extra URLconfs.After
mastermoved on and theownerargument went in (each pass posted below as a summary, in full on OpenConceptLab/ocl_online#620):describe()assertions the contract test already covers), one declined.ownerdocstring claimed, and a test policy that dropped blocked and unlimited values. Two P2s accepted: an inheritedconsume()counts on the acting user's counter, and a rollback past this PR has to delete windowed counters first. Eight P3s recorded.After the first review (in full on OpenConceptLab/ocl_online#620):
migratedon't run the check), and one recorded (deploy jobs don't wait for service stability, which predates this PR). Four P3s recorded.unitsby position). Codex check of that commit: clean.The branch brought in
masterby merging, not rebasing. It merged as squash 42bd393, whose tree is f4fa8c5 merged withmasterat 4423361.🤖 Generated with Claude Code
https://claude.ai/code/session_01AaZ4qTgak472qGyADUGEGq