store: a decline reason is for a person to read, not for an app to branch on
Review findings from an independent read of stages 4-6, and the ruling that
settles the one contradiction it found.
§4 said a decline reason must not be tenant-readable; §8 said member status
renders the reason. Both were written meaning something true, and the build
had to pick one — it picked §8, which handed every account member and every
Admin service credential the applicant's note, the reason, and the address of
the operator who decided.
Ruled 2026-09-08: a person reads the reason, a machine does not, and the
deciding operator's address reaches neither. Requiring a reason on decline is
pointless if the account never learns it — that is the four-day silence of §1
in a new hat — but an operator stays answerable to the deployment, not to the
account they just declined. storeEntitlementWire is that projection, and it
withholds Decision.By STRUCTURALLY: the type has no such field, so no future
edit can restore the leak by flipping a condition.
Two things the review had not reached, found while implementing it:
The apply door leaked identically. It answered with the record, so an owner
re-applying to an already-decided account was handed the deciding operator's
address straight back.
Projecting the decision door broke the operator's own confirmation. `carlos
store decline` prints the reason back as proof of what it recorded, and the
operator was about to get their own sentence returned empty. Tenant-facing
doors project; the operator's own doors answer with the record.
Also from the review:
printStoreCreate now uses the four-state printer. It told every non-approved
account to run `carlos store apply`, including one whose application was
already pending — and following that instruction rewrites Applied.At, which
the queue sorts on, so an applicant obediently sent themselves to the back of
the line they were already standing in.
The provision walk's deadline starts after the lock rather than before it.
This is the door that queues in ordinary use, and a request that waited out a
full-length walk ahead of it reached the lock holding a spent context and
failed its first Get for no reason.
printStoreApprovals no longer dereferences a nil Applied block.
Spec amended with the ruling as a table, and §7's route corrected to the
/api/cli family that actually shipped rather than the /a/{sqid} one it
described.
Reviewed by Claude Fable 5.1; the mechanisms it checked hardest — the
authority gate, the edge ACL, lock-then-reread, one-key-per-app, derived
state and CAS — it confirmed correct in source rather than from test names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CfUNFHm89j2mj5q7vBHsfH
6 files changed,
+206
−9
docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md+24 −1internal/console/apistore.go+71 −1internal/console/apistore_entitlement.go+18 −3internal/console/apistore_entitlement_test.go+73 −0internal/console/apistore_provision.go+9 −3store.go+11 −1
CI passed — run details
diff --git a/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md b/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md| index 75c5df2fb..7aa1055e5 100644 |
| --- a/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md |
| +++ b/docs/superpowers/specs/2026-09-02-object-storage-self-serve-design.md |
| @@ -242,7 +242,7 @@ and `TestEveryClosedDoorRefusesAServiceCredential` cover the new patterns. |
| **(decision) One CLI command, two doors.** `carlos store create` calls the |
| existing declare door, then — when the reply says the account is approved and |
| -the deployment has a backend — a new `POST /a/{sqid}/apps/{app}/store/provision` |
| +the deployment has a backend — a new `POST /api/cli/apps/{account}/{app}/store/provision` |
| door, member-gated and entitlement-gated. The member still runs one command |
| (§3's ruling); the wire has two steps. |
| @@ -344,6 +344,29 @@ carlos store decline --account <a> --reason "..." operator |
| - *approved* — no extra line; their store provisions on declare |
| - *declined* — the reason, and that they may apply again |
| +**(ruling, 2026-09-08) What a tenant may read of the operator's half.** §4 |
| +says a decline reason must not be tenant-readable; this section says status |
| +renders the reason. Both were written meaning something true, and the |
| +implementation had to pick one. The resolution: |
| + |
| +| | person | machine | |
| +|---|---|---| |
| +| `Decision.By` | withheld | withheld | |
| +| `Decision.Reason` | rendered | withheld | |
| +| `Applied.By` / `Applied.Note` | rendered | withheld | |
| + |
| +Requiring a reason on decline is pointless if the account never learns it — |
| +that is §1's four-day silence in a new hat — so a person reads it. A machine |
| +credential gets the STATE and nothing else: a reason is written for someone to |
| +read and act on, not for an app to branch on. The deciding operator's address |
| +travels to neither; an operator stays answerable to the deployment, not to the |
| +account they just declined. |
| + |
| +§4's sentence therefore means what its placement argument always meant: not |
| +readable by a tenant's BOXES, through the fleet read ACL. The console's own |
| +answer is narrowed by projection (`storeEntitlementWire`), which is a separate |
| +mechanism with a separate test. |
| + |
| **(decision) Two existing printed sentences must change, not just gain a |
| neighbour.** `store.go:261` always says "nothing is provisioned until a |
| deployment operator grants it", and `store.go:348` says "waiting on a |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index eb222bf10..faf774dcc 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -109,6 +109,76 @@ func storeEffectivePrefix(rec *objstore.Record) string { |
| // fails it. Disclosure-wise it is the same class as secrets_pubkey and the |
| // apps domain: a deployment-wide fact about the platform, carrying nothing |
| // about any tenant, and every member of it can already see the consequences. |
| +// storeEntitlementWire is the TENANT-FACING projection of an Entitlement, and |
| +// exists because the record is operator commentary as much as it is tenant |
| +// fact. Decision.By is the address of the person who decided; Decision.Reason |
| +// is what they said about this account. §4 of the self-serve spec keeps both |
| +// out of the fleet read ACL by putting the record under store/ — this keeps |
| +// them out of the console's own answer, which is the other road a tenant could |
| +// read them by, and the one an Admin service credential travels. |
| +// |
| +// What travels, and to whom: |
| +// |
| +// Decision.By never. The operator stays answerable to the deployment, |
| +// not to the account they just declined. |
| +// Decision.Reason to a PERSON only. Requiring a reason on decline is |
| +// pointless if the account never learns it — that is the |
| +// four-day silence of §1 in a new hat — but it is written |
| +// for someone to read and act on, not for an app to branch |
| +// on. |
| +// Applied.* the applicant's own text about themselves, so it goes |
| +// back to a person untouched. A machine gets the At alone: |
| +// it needs the STATE, and free text a colleague typed is |
| +// not state. |
| +// |
| +// The shape mirrors Entitlement's tags exactly, so the CLI decodes this into |
| +// an objstore.Entitlement and the four-state printers keep working — a |
| +// withheld field simply arrives as its zero value. |
| +type storeEntitlementWire struct { |
| + Applied *storeAppliedWire `json:"applied,omitempty"` |
| + Decision *storeDecisionWire `json:"decision,omitempty"` |
| +} |
| + |
| +type storeAppliedWire struct { |
| + By string `json:"by,omitempty"` |
| + At time.Time `json:"at"` |
| + Note string `json:"note,omitempty"` |
| +} |
| + |
| +type storeDecisionWire struct { |
| + Approved bool `json:"approved"` |
| + At time.Time `json:"at"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| + |
| +// The caller is read from ctx rather than passed: every door that renders a |
| +// store reply builds its ctx from the request, so the principal is already |
| +// there, and a parameter threaded through five call sites is five chances to |
| +// pass the wrong one. A ctx with NO principal is treated as a machine — the |
| +// fail-closed direction, and the operator walk doors are the only callers in |
| +// that position, where the withheld reason is one they wrote themselves. |
| +func tenantEntitlement(e *objstore.Entitlement, ctx context.Context) *storeEntitlementWire { |
| + if e == nil { |
| + return nil |
| + } |
| + p, ok := principalFrom(ctx) |
| + person := ok && p.Kind == PrincipalUser |
| + out := &storeEntitlementWire{} |
| + if e.Applied != nil { |
| + out.Applied = &storeAppliedWire{At: e.Applied.At} |
| + if person { |
| + out.Applied.By, out.Applied.Note = e.Applied.By, e.Applied.Note |
| + } |
| + } |
| + if e.Decision != nil { |
| + out.Decision = &storeDecisionWire{Approved: e.Decision.Approved, At: e.Decision.At} |
| + if person { |
| + out.Decision.Reason = e.Decision.Reason |
| + } |
| + } |
| + return out |
| +} |
| + |
| func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) { |
| entitlement, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| if err != nil { |
| @@ -132,7 +202,7 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec |
| } |
| return map[string]any{ |
| "app": app, |
| - "entitlement": entitlement, |
| + "entitlement": tenantEntitlement(entitlement, ctx), |
| "backend": s.cfg.StoreDeployment.Backend, |
| "declared": rec.Declared, |
| "grant": rec.Grant, |
diff --git a/internal/console/apistore_entitlement.go b/internal/console/apistore_entitlement.go| index 1f5d8013f..de21df4a7 100644 |
| --- a/internal/console/apistore_entitlement.go |
| +++ b/internal/console/apistore_entitlement.go |
| @@ -13,7 +13,22 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// The apply door answers the OWNER, so its entitlement goes out through the |
| +// same tenant projection the store doors use (tenantEntitlement): an owner |
| +// re-applying to an already-decided account would otherwise be handed the |
| +// deciding operator's address straight back. The approvals and decision |
| +// doors are the operator's own and answer with the record itself. |
| type storeEntitlementReply struct { |
| + Account string `json:"account"` |
| + Entitlement *storeEntitlementWire `json:"entitlement"` |
| +} |
| + |
| +// storeOperatorEntitlementReply is the OPERATOR's answer, and carries the |
| +// record itself. The projection above withholds the deciding operator's |
| +// address and, from a machine, the reason — neither of which makes sense to |
| +// withhold from the operator who just typed the reason in. `carlos store |
| +// decline` prints it straight back as its confirmation. |
| +type storeOperatorEntitlementReply struct { |
| Account string `json:"account"` |
| Entitlement *objstore.Entitlement `json:"entitlement"` |
| } |
| @@ -50,7 +65,7 @@ func (s *Server) handleAPIStoreApply(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if e != nil && e.Approved() { |
| - writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: e}) |
| + writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)}) |
| return |
| } |
| e = &objstore.Entitlement{Applied: &objstore.Applied{By: p.Address, At: time.Now(), Note: strings.TrimSpace(req.Note)}} |
| @@ -64,7 +79,7 @@ func (s *Server) handleAPIStoreApply(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| s.audit(acct, "", p.Address, "store-apply", e.Applied.Note) |
| - writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: e}) |
| + writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: tenantEntitlement(e, ctx)}) |
| } |
| func (s *Server) storeEntitlementOperator(w http.ResponseWriter, r *http.Request) (string, bool) { |
| @@ -167,7 +182,7 @@ func (s *Server) handleAPIStoreDecision(w http.ResponseWriter, r *http.Request, |
| action = "store-approve" |
| } |
| s.audit(acct, "", addr, action, detail) |
| - writeJSON(w, http.StatusOK, storeEntitlementReply{Account: acct.Sqid, Entitlement: e}) |
| + writeJSON(w, http.StatusOK, storeOperatorEntitlementReply{Account: acct.Sqid, Entitlement: e}) |
| } |
| func (s *Server) handleAPIStoreApprovals(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/console/apistore_entitlement_test.go b/internal/console/apistore_entitlement_test.go| index b90f85917..41d8a14f2 100644 |
| --- a/internal/console/apistore_entitlement_test.go |
| +++ b/internal/console/apistore_entitlement_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| func entitlementApplyURL(fx *consoleFixture, account string) string { |
| @@ -509,3 +510,75 @@ func TestAPIStoreApprovalsTrustsTheKeyForTheAccount(t *testing.T) { |
| t.Fatalf("application account = %v, want key account %s", got, fx.sqid) |
| } |
| } |
| + |
| +// The projection tests (§4 versus §8, resolved 2026-09-08: a person may read |
| +// why they were declined, a machine may not, and the deciding operator's |
| +// address travels to neither). These read the STORE door rather than the apply |
| +// door because that is the one an app's own service credential can reach. |
| + |
| +func declinedStoreFixture(t *testing.T, code string) *consoleFixture { |
| + t.Helper() |
| + fx, _ := storeFixture(t, code) |
| + now := time.Now() |
| + putRecord(t, fx, "titogo", &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: now, By: "alice@keymail.dev"}, |
| + }) |
| + putEntitlement(t, fx, release.OpsAccount, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "alice@keymail.dev", At: now, Note: "customer uploads"}, |
| + Decision: &objstore.Decision{ |
| + Approved: false, By: "paul@keymail.dev", At: now, |
| + Reason: "not until the account is on a card", |
| + }, |
| + }) |
| + return fx |
| +} |
| + |
| +func TestAPIStoreReplyGivesAMemberTheReasonButNotTheOperator(t *testing.T) { |
| + fx := declinedStoreFixture(t, "code-store-project-person") |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-store-project-person-2") |
| + |
| + status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v, want 200", status, out) |
| + } |
| + e := entitlementBlock(t, out) |
| + d := block(t, e, "decision") |
| + if d["reason"] != "not until the account is on a card" { |
| + // The whole point of requiring a reason is that the account learns it. |
| + t.Fatalf("decision.reason = %v, want the reason a person was given", d["reason"]) |
| + } |
| + if _, present := d["by"]; present { |
| + t.Fatalf("decision.by = %v, want the deciding operator's address withheld from the tenant", d["by"]) |
| + } |
| + if applied := block(t, e, "applied"); applied["note"] != "customer uploads" { |
| + t.Fatalf("applied.note = %v, want the applicant's own text returned to them", applied["note"]) |
| + } |
| +} |
| + |
| +func TestAPIStoreReplyWithholdsTheReasonFromAServiceCredential(t *testing.T) { |
| + fx := declinedStoreFixture(t, "code-store-project-service") |
| + svc := serviceToken(t, fx.st, OpsAccountID, "uploader", "titogo", svcrole.Admin) |
| + |
| + status, out := getJSON(t, storeURL(fx, "titogo"), svc) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v, want 200", status, out) |
| + } |
| + e := entitlementBlock(t, out) |
| + d := block(t, e, "decision") |
| + // The state still has to arrive — a machine reading "not approved" is the |
| + // whole reason the block travels at all. |
| + if d["approved"] != false { |
| + t.Fatalf("decision.approved = %v, want false", d["approved"]) |
| + } |
| + for _, k := range []string{"reason", "by"} { |
| + if v, present := d[k]; present { |
| + t.Fatalf("decision.%s = %v, want it withheld from a service credential", k, v) |
| + } |
| + } |
| + applied := block(t, e, "applied") |
| + for _, k := range []string{"note", "by"} { |
| + if v, present := applied[k]; present { |
| + t.Fatalf("applied.%s = %v, want it withheld from a service credential", k, v) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apistore_provision.go b/internal/console/apistore_provision.go| index f444b2055..485789db2 100644 |
| --- a/internal/console/apistore_provision.go |
| +++ b/internal/console/apistore_provision.go |
| @@ -21,9 +21,9 @@ func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) |
| storeNoBucket(w) |
| return |
| } |
| - ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout) |
| - defer cancel() |
| - if _, ok := s.storeProvisionApproval(ctx, w, acct); !ok { |
| + preCtx, preCancel := context.WithTimeout(r.Context(), storeTimeout) |
| + defer preCancel() |
| + if _, ok := s.storeProvisionApproval(preCtx, w, acct); !ok { |
| return |
| } |
| p, _ := principalFrom(r.Context()) |
| @@ -39,6 +39,12 @@ func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| defer s.storeWalks.lock(acct.Sqid, app)() |
| + // The walk's deadline starts HERE rather than at the top of the handler. |
| + // This is the door that queues in ordinary use, and a request that waited |
| + // out a full-length walk ahead of it would otherwise reach the lock |
| + // holding a spent context and fail its first Get for no reason at all. |
| + ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout) |
| + defer cancel() |
| // A queued request may have waited while an operator revoked approval. |
| // An already-running walk completes; decline does not tear down estate. |
| e, ok := s.storeProvisionApproval(ctx, w, acct) |
diff --git a/store.go b/store.go| index 2781bc93d..6c6277912 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -422,7 +422,11 @@ func printStoreCreate(w io.Writer, res apiStoreReply) { |
| return |
| } |
| if !res.Entitlement.Approved() { |
| - mutedf(w, "this account is not approved for object storage — `carlos store apply`") |
| + // The four-state printer, not the one-line "go apply": a PENDING |
| + // applicant told to apply again overwrites their own Applied.At, and |
| + // the queue sorts oldest first, so following that instruction sends |
| + // them to the back of the line they are already standing in. |
| + printStoreEntitlement(w, res.Entitlement) |
| } |
| if res.Backend == "" { |
| warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+ |
| @@ -572,6 +576,12 @@ func printStoreApprovals(w io.Writer, res apiStoreApprovalsReply) { |
| return |
| } |
| for _, e := range res.Applications { |
| + // The server sends only Pending() entitlements, so Applied is always |
| + // there — but this printer is fed by whatever console it is pointed |
| + // at, and a CLI should not panic because a server changed its mind. |
| + if e == nil || e.Applied == nil { |
| + continue |
| + } |
| fmt.Fprintf(w, "%s — applied by %s on %s\n", e.Account, e.Applied.By, e.Applied.At.Format(time.RFC3339)) |
| if e.Applied.Note != "" { |
| fmt.Fprintf(w, " note: %s\n", e.Applied.Note) |