Repository navigation
Report a visitor's explain refusal as the 401 they receive, and remove the unused OperationScope.proposed - #6589
Conversation
No production code reads a create's proposed document: the gate reads a GateScope that leaves it out, and a create is judged by the card it mints or the card it is anchored on. Drop the field, its staging write, and the tests that asserted on it, and say in OperationScope's comment what the gate does read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review checked that nothing else reads proposed, that the edited comments are accurate, that a create is still staged and judged the same way, and that the rewritten tests can still fail. It did not run the realm-server suite.
No behavior problems: nothing in the repo reads the field, and the write lock judges a create from the card handed to dischargePendingDecision, never from the scope. One comment on ResolvedEnvelopeEntry.scope is now stale (blocking), and the derive test lost its only check of what does not carry over (non-blocking).
- Update the stale
scopecomment onResolvedEnvelopeEntry. Details are in the file comment on envelope.ts. - Replace the duplicated caller assertion in the derive test with a check that a trace does not carry over. Details are in the comment on card-operations-dispatch-test.ts.
- Nit, in a file outside the diff: the
GateScopecomment ingate.tsstill says "A create's proposed document is not among them". That reads as if the scopeGateScopenarrows had such a field. SinceOperationScope's comment now covers this, the sentence can be dropped or reworded to talk about the create's payload. Non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] The scope member's comment on ResolvedEnvelopeEntry still says the scope carries, "once stageWriteEntry has run — the document a create would write". After this change staging returns the entry's scope unchanged, so that clause now describes a field that no longer exists. Suggested wording:
// This entry's own view of the batch's scope: the batch's caller and row
// memo.
scope: OperationScope;Regression (a stale comment this PR leaves behind). Blocking, because the ticket's bar is that no comment still presents the payload as part of the scope.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6533a8f: the scope comment on ResolvedEnvelopeEntry now says only what the scope carries, the batch's caller and row memo.
| let unattributed = entry.derive({ caller: { kind: 'unattributed' } }); | ||
| assert.deepEqual(unattributed.caller, { kind: 'unattributed' }); | ||
| assert.strictEqual( | ||
| unattributed.proposed, | ||
| undefined, | ||
| 'a proposed document is one invocation’s and does not carry over', | ||
| assert.deepEqual( | ||
| entry.caller, | ||
| batch.caller, | ||
| 'deriving leaves the scope it came from as it was', | ||
| ); |
There was a problem hiding this comment.
[Claude Code 🤖] Two problems with this test:
- The new assertion repeats
assert.deepEqual(entry.caller, batch.caller)from four lines up. It can only fail ifderivemutatedentryin place, andscopeForalways builds a fresh object, so it never fails. - With
proposedgone, nothing tests the other half ofderive's contract: the part that does not carry over. Its comment now says "a trace belongs to one invocation and never does", andderiveis the only place a trace is attached (capabilities.tsdoesscope.derive({ trace })). The removedproposedassertions were the only check that a per-invocation member is dropped.
Swapping the duplicate for a trace check would cover it:
let traced = entry.derive({ trace: someTrace });
assert.strictEqual(traced.trace, someTrace);
assert.strictEqual(
traced.derive({}).trace,
undefined,
'a trace is one invocation’s and does not carry over',
);Regression in coverage, not in behavior. Non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 6533a8f. The duplicate assertion is gone. The test now derives with a GateTrace, then checks three things: the derived scope carries it, the original scope still has none, and a further derive that names no trace drops it. That is the member-does-not-carry-over check the old proposed assertions covered.
…that a trace does not carry over through derive Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
[Claude Code 🤖] On the |
…e-the-unused-operationscopeproposed
Explain's refusal is what its actor would receive. For a caller who isn't signed in on a realm they may not read, the gate's refusals (no grant, a predicate that does not hold, an operation that reads the caller, an acting user that does not resolve) were reported as the non-reader's 404, while the realm answers such a caller 401 actor-required. The realm's rule is lifted into one refusalSeenBy beside refusalForNonReader, which the realm answers with and explain reports, so the two cannot disagree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review covers the commit that makes explain report a visitor's refusal as the realm sends it. I checked whether explain's refusal matches the realm's answer on every route a visitor can reach: private realms and realms anyone may read, across the gate's refusal reasons. The OperationScope.proposed removal was reviewed earlier and isn't revisited here.
No blocking issues. The realm's own answers are unchanged, and on a private realm explain now agrees with every route, including predicate-threw. One pre-existing gap remains on realms anyone may read, and the new contract makes it matter.
- On a realm anyone may read, explain's per-operation
opensToAnonymousearly return answers 401 where the envelope answers 403 for an operation the policy doesn't open to visitors. See the comment on the board half of the new test. Pre-existing and non-blocking; fix it here or file it before merge. - The comment on
refusalSeenByclaims more coverage than the code gives: three read paths still userefusalForNonReaderdirectly. See the thread in types.ts. Non-blocking.
Adjacent and out of scope: on a realm whose ACL lets anyone write, a visitor's declared write that reads actor() is explained as allowed/acl, because the gate returns a coarse decision. The envelope refuses it 401 at its needsActor check, and explain has no counterpart to that check.
What this review couldn't settle: the realm-policy suites can't run on the local stack right now, so the new test and the 403-vs-401 split above were traced in code, not executed.
| // A realm anyone may read tells such a caller its refused write as the | ||
| // gate refused it, and explain says the same. |
There was a problem hiding this comment.
[Claude Code 🤖] On a realm anyone may read, explain and the envelope still disagree about a visitor's write when the policy doesn't open that particular operation to visitors. Can you either close that gap or narrow the stated contract?
- Explain: the early return in
explain()(explain.ts, theactor.kind !== 'user' && coarseDeclined !== 'none' && !opensToAnonymous(core, question.operation)block) answers 401actor-requiredfor any operation missing frompolicy.anonymous.operations. - The envelope: it admits the visitor through
ANONYMOUS_OPERATIONS(operations: 'any'), socallerForlets them in whenever the policy opens anything. The gate then filters to anonymous grants, finds none and refusesno-grant.refusalSeenBypasses that through forreadDeclined: false, so the visitor gets 403operation-not-permitted.
On this fixture, explain(BOARD_PETITION, 'update', BOARD_POLICY) reports 401, while invoke('update', …) POSTed to the board without credentials gets 403. A named write the board's policy doesn't open (a second declared operation on Petition) has the same split, and the envelope is the only route that reaches it. I traced this through the code but couldn't run it: the realm-policy suites can't run against the local stack right now.
On a private realm the per-operation check is harmless, because refusalSeenBy turns the gate's refusal into the same 401. The fix that keeps both realms right is to apply the per-operation early return only when coarseDeclined === 'all'. For 'writes', return 401 only when the policy opens nothing to visitors (mirroring callerFor's opened.operations.size > 0), and otherwise let the gate decide. One wrinkle: the card+json PATCH/DELETE/POST routes admit by the specific base operation (ANONYMOUS_CARD_UPDATE etc.), so for update/delete/create those routes send 401 where the envelope sends 403. The contract needs to name which route explain describes. Please also add an unopened operation on the board to this test.
Pre-existing (the early return predates this commit), but this commit and the paired skills doc now state that explain's refusal equals what the realm sends a visitor, so it becomes load-bearing. Non-blocking for the refactor itself; fix it here or file it before merge.
There was a problem hiding this comment.
[Claude Code 🤖] Tracked in CS-13613, together with the review body's case of a realm anyone may write and a declared write that reads actor(). It isn't a change explain can make on its own: on a realm anyone may read, the realm's routes disagree with each other. The envelope admits a visitor whenever the policy opens anything and then refuses with 403, while the card+json writes admit per base operation and answer 401. So the fix is first a decision: does the envelope admit per operation, or does explain say which route it describes? Then explain follows. This PR leaves that early return as it was.
There was a problem hiding this comment.
[Claude Code 🤖] Confirmed, and CS-13613 is narrowed (by a correcting comment on the ticket). On a realm anyone may read, the realm declines a visitor's write as 'all', so the envelope, the card+json writes and explain's early return all answer 401. What's left in CS-13613 is the realm anyone may write with a declared write that reads actor(), plus the capability check's lanes raised in the latest review.
| // The realm answers its callers with this, and an explain reports it as the | ||
| // refusal its actor would receive, so the two cannot disagree. |
There was a problem hiding this comment.
[Claude Code 🤖] "The realm answers its callers with this … so the two cannot disagree" is broader than the code. Only the envelope (handleOperations) and the card+json write refusal (#cardWriteRefusal) call #refusalSeenBy. The other three read paths call refusalForNonReader directly:
- the source/byte read (the
coarseDeclined ? refusalForNonReader(e.error) : e.errorin thereadSourcewrapper), - the card+json
HEAD(refusalForNonReader(e.error).code === 'target-not-found'), - the card+json read assembly (
caller.coarseDeclined ? refusalForNonReader(e.error) : e.error, whose comment already says "see#refusalSeenBy").
Each of those reaches the 401 by its own downstream check instead: #bytesNotThere, or assembly.kind === 'not-found' && requestContext.anonymousCaller. They agree today, but the "cannot disagree" guarantee doesn't cover them. Either route those three through #refusalSeenBy, or reword the comment to name the two routes it does cover. Non-blocking, a wording or consistency fix in this PR.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in 8cfbdaf. The comment now names the routes that answer with refusalSeenBy (the operations envelope and the card+json writes). It also says that the card+json read and HEAD and the byte serves apply refusalForNonReader themselves and reach the same answer by their own route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ealm The realm asks whether a caller it declined a write could read only of a signed-in caller, so it declines a visitor's write outright even on a realm anyone may read, and answers its refusal 401 actor-required. Explain now derives the same, so its refusal for such a write is that 401. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
habdelra
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review covers only the newest commit, which makes explain judge a visitor's write as declined outright. I checked coarseDeclinedFor against Realm#coarseDeclined for each actor kind and lane, the other explain answers that 'all' now feeds, and the routes a visitor can reach. I did not run the realm-policy suites.
No blocking issues. coarseDeclinedFor now matches the realm for a visitor's read and write on a private realm, a realm anyone may read, and a realm anyone may write. The early actor-required return tests only !== 'none', so the change does not touch it.
- The open thread on the unopened operation (the
explainearly return, on the anonymous-named-operation test) no longer rests on a true premise. That thread and its reply both assume the envelope gives a visitor's unopened write 403, becausereadDeclinedis false on a realm anyone may read. Under#coarseDeclinedit is'all'for a visitor'sPOST. So the gate'soperation-not-permittedgoes throughrefusalForNonReaderto 404 and then toAUTHENTICATION_REQUIRED. The envelope therefore answers 401, the same as the card+json writes and explain's per-operation early return. On a realm anyone may read, the routes no longer disagree, so the decision the follow-up ticket waits on is moot for that case. Could you re-check this and update the ticket's scope to what is left (the realm anyone may write, and the write that readsactor())? Non-blocking. - The capability check is the one twin that still treats a visitor as a reader.
#capabilityLanesin realm.ts setscoarseDeclined = readAllowed ? 'writes' : 'all'without the signed-in condition, and its comment says the gate judges a visitor's writes "as it judges a reader's". On a realm anyone may read whose policy opens some write to visitors, a visitor's refused write pair comes back non-barewith a reason (operation-not-permitted,policy-predicate-failed, or a create'sconditional: true). The invocation itself answers 401actor-required, andwritesRefusedclaims to report "the code its refusal comes closest to". Setting'all'there would also put the visitor's reads through the policy, so the fix probably needsbareper lane rather than a differentCoarseDeclined. This is pre-existing and not this commit's bug. The new comment in types.ts now states the opposite rule, though, so could you either align the check or file it? Follow-up, non-blocking.
CI on this head is still running. The wording nit on the new types.ts comment is inline.
| // lets read the realm is told the refusal as it is. A caller who isn't signed | ||
| // in is never one of those on a write: the realm asks whether a caller it | ||
| // declined a write could read only of a signed-in caller. |
There was a problem hiding this comment.
[Claude Code 🤖] "A caller who isn't signed in is never one of those on a write" is wrong as written: on a realm anyone may read, the ACL does let a visitor read. The realm just never asks about it for a visitor (#recordCoarsePermission runs #readProbe only when #policyJudges holds, and that needs an authenticatedUser). Since this comment defines the rule refusalSeenBy callers follow, could it say what the realm does instead of claiming something about the ACL? For example:
// lets read the realm is told the refusal as it is, where the realm asks: it
// asks whether a caller it declined a write could read only of a signed-in
// caller, so a visitor's refused write is told as a non-reader's even on a
// realm anyone may read.Wording on this PR's own comment. Non-blocking.
There was a problem hiding this comment.
[Claude Code 🤖] Fixed in b5b856f, using your wording.
…L allows Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
[Claude Code 🤖] On the |
Important
Merge this before cardstack/boxel-skills#174, which documents a visitor's explain refusal as the 401 this PR makes explain report.
This PR makes two changes to how the operation policy's create lane and explain describe themselves.
Explain reports the refusal a visitor actually receives. An explain's
refusalis documented as the{ status, code }its actor would receive. For an actor who isn't signed in, explain reported the gate's refusals as a non-reader's 404target-not-found, or as the gate's 403 for a write on a realm anyone may read. That coveredno-grant,predicate-false,reads-actor, and a write whose acting user doesn't resolve. The realm answers such a visitor 401actor-required. The realm's rule now lives in one place,refusalSeenByincard-operations/types.tsbesiderefusalForNonReader, together with theAUTHENTICATION_REQUIREDerror it returns.Realm#refusalSeenByanswers with it and explain reports it, so the two can't disagree. A realm anyone may read declines a visitor's write outright, because the realm asks whether a caller it declined a write could read only of a signed-in caller. Explain'scoarseDeclinedFornow derives the same, so a visitor's refused write there is reported as the same 401. Signed-in actors are unchanged. The new test inrealm-policy-anonymous-named-operation-test.tsexplains each of those refusals for a visitor, sends the same request as one, and checks that both answers are 401actor-required, on a private realm and on a realm anyone may read.OperationScope.proposedis gone. It held a create's staged payload:stageWriteEntryset it, andderivedropped it for every other invocation. No production code read it. The gate reads aGateScopethat leaves the payload out, because the payload names the type the caller claims. A create against a type is judged by the card it mints once staged, and a named create anchored on a card by that card. The field is removed fromOperationScope,ScopeInvocation,newOperationScopeandderive, and staging returns the entry's scope unchanged.OperationScope's andResolvedEnvelopeEntry.scope's comments now say what the gate reads. The staging tests now assert on the staged entry's payload, and the derive test now checks that a trace, the member that belongs to one invocation, doesn't carry over.Not in this PR: on a realm anyone may write, explain reports a visitor's declared write that reads
actor()as allowed, while the realm refuses it 401. Tracked in CS-13613.Linear: CS-13348, CS-13612
🤖 Generated with Claude Code