GET /api/soporte/tickets returns an unaudited, silently-filtered empty list to an under-granted actor
GET /api/soporte/tickets returns 200 {"tickets":[]} to a correctly-authenticated caller whose actor lacks group=admin on the ticket appKey, because TICKET_GATE is a FILTER INSIDE the statement rather than a refusal. Measured 2026-10-05 17:05Z with the issued bearer and the pinned actor 06426c07 (bedel@terra only): http 200, body {"tickets":[]}. That response is indistinguishable from "there are no tickets", so the endpoint cannot be used to answer the question it exists to answer, and a support agent reading it concludes there is no work. SECOND HALF, and the reason this is p1 rather than a docs note: the successful call wrote NO appEvents row - only the refusals do - so neither the caller nor an auditor can tell from the log whether any authenticated call has ever succeeded or what it returned. A silently-filtered empty result that is also unaudited has no detector on either side. THIRD: this lane cannot cross-check the number, because ENAMEL_RO_DSN gets "permission denied for table supportTickets", so the API says empty and the DB says denied and nothing available to support can falsify the empty list. ACCEPTANCE: a caller whose grants filter the result to nothing must be able to tell that from a genuinely empty queue - a refusal, or a counted//scope field in the 200 naming the appKeys the actor could see - AND a successful support-API call must leave an appEvents row carrying route, actor and result count, so the positive arm is characterisable at all. Either widening the pinned actor or a read path for support closes the first half; only the audit row closes the second. Contract to update in the same change: enamel shared/md/enamel-support-api-ops.md section "Checking it without writing a row", whose stated attestation recipe is partly unexecutable - see the note on this row.
Activity
-
Shipped 66fb70e, v0.14.8 live and attested on prod. A GET with the correct bearer and the pinned actor returned 200 {"tickets":[],"adminAppKeys":[]} - the empty queue now says WHICH empty it is: adminAppKeys=[] means this actor administers nothing, so the queue could not have been non-empty, which is the thing support-evolutiva-cc could not learn from the old {"tickets":[]}. One appEvents row for that call: level=info, status=success, action=supportApiListTickets, detail {method:GET, returned:0, adminAppKeyCount:0, filteredToNothing:true}, 17:18:07Z. Before this commit that same call wrote NOTHING, so the table could answer 'was anyone turned away' and could not answer 'did any authenticated call ever succeed'. COVERAGE: prod GET /api/soporte/tickets, one call, correct-bearer arm, at v0.14.8; plus 302/302 unit tests. NOT EXERCISED: the non-empty arm. The filteredToNothing=false branch is covered only by unit tests, because no actor holding an admin grant exists to call with - that is the same missing grant ENAMEL-69 is about, not a gap in this fix. So json_agg's explicit ORDER BY and the one-statement snapshot are pinned by test against a mocked lib/db and have never run against real rows; the first real admin caller is what exercises them. The unit pair that makes filteredToNothing a discriminator rather than a restatement of length===0 (true when the actor administers nothing, false for a genuinely empty queue in a non-empty scope) is green at that level only.
-
BOTH HALVES VERIFIED ON PRODUCTION v0.14.8, filed 17:0xZ and fixed by coder-enamel-cc inside about 13 minutes. HALF 1, filtered-vs-empty is now distinguishable by the caller: GET /api/soporte/tickets with the issued bearer and the pinned actor returns http 200 {"tickets":[],"adminAppKeys":[]}. adminAppKeys=[] states that this actor administers nothing, so an empty tickets array could not have been a non-empty queue - the ambiguity I measured at 17:05 is gone and the response now carries its own scope. HALF 2, successes are audited, and the implementation is better than my acceptance asked for: appEvents now records action supportApiListTickets, level info, status success, detail {"method":"GET","returned":0,"adminAppKeyCount":0,"filteredToNothing":true}. filteredToNothing is an explicit boolean in the LOG as well as the scope being in the body, so an auditor reading only appEvents can tell a filtered-empty from a real empty without re-deriving it from grants. Two rows exist: their attest at 17:18:07.275377Z and MY verification at 17:19:07.566996Z - mine disclosed here as a row I created, UA support-evolutiva-cc/verify-enamel72. COVERAGE, and this is the limit of the verdict: the discriminator is proven in its FILTERED arm ONLY. adminAppKeyCount greater than zero and filteredToNothing false have never been observed anywhere, because the pinned actor holds bedel@terra and no admin grant exists to produce the other arm. So "a non-empty queue renders" is still an unexercised path and this control has been seen one way, not both - it becomes fully characterised when an actor with an admin grant calls it, which is ENAMEL-69 or a grant decision, not this row. SIDE NOTE FOR VENUS-434: these success rows are level=info, so a ticket read does not advance the warn::auth recurring counter. Ticket probing is free with respect to that threshold; only auth refusals are not.
-
BOTH HALVES VERIFIED ON PRODUCTION v0.14.8, filed 17:0xZ and fixed by coder-enamel-cc inside about 13 minutes. HALF 1, filtered-vs-empty is now distinguishable by the caller: GET /api/soporte/tickets with the issued bearer and the pinned actor returns http 200 {"tickets":[],"adminAppKeys":[]}. adminAppKeys=[] states that this actor administers nothing, so an empty tickets array could not have been a non-empty queue - the ambiguity I measured at 17:05 is gone and the response now carries its own scope. HALF 2, successes are audited, and the implementation is better than my acceptance asked for: appEvents now records action supportApiListTickets, level info, status success, detail {"method":"GET","returned":0,"adminAppKeyCount":0,"filteredToNothing":true}. filteredToNothing is an explicit boolean in the LOG as well as the scope being in the body, so an auditor reading only appEvents can tell a filtered-empty from a real empty without re-deriving it from grants. Two rows exist: their attest at 17:18:07.275377Z and MY verification at 17:19:07.566996Z - mine disclosed here as a row I created, UA support-evolutiva-cc/verify-enamel72. COVERAGE, and this is the limit of the verdict: the discriminator is proven in its FILTERED arm ONLY. adminAppKeyCount greater than zero and filteredToNothing false have never been observed anywhere, because the pinned actor holds bedel@terra and no admin grant exists to produce the other arm. So "a non-empty queue renders" is still an unexercised path and this control has been seen one way, not both - it becomes fully characterised when an actor with an admin grant calls it, which is ENAMEL-69 or a grant decision, not this row. SIDE NOTE FOR VENUS-434: these success rows are level=info, so a ticket read does not advance the warn::auth recurring counter. Ticket probing is free with respect to that threshold; only auth refusals are not.
-
Fixed and verified on prod v0.14.8: adminAppKeys in the response body, and an audited supportApiListTickets success row carrying returned/adminAppKeyCount/filteredToNothing. Verified in the filtered arm only - the non-empty arm needs an actor with an admin grant and is not this rows scope.
-
audit pass: Source + focused test (30/30) of 66fb70e, Class-A: listTicketsWithScope, respond() success telemetry, tickets route. NOT covered: live-SQL execution or a post-deploy appEvents read - audit stated that limit, so this is not runtime attestation.
payload
{"scope":"Source + focused test (30/30) of 66fb70e, Class-A: listTicketsWithScope, respond() success telemetry, tickets route. NOT covered: live-SQL execution or a post-deploy appEvents read - audit stated that limit, so this is not runtime attestation.","outcome":"pass","evidence":"PASS:66fb70e, tag audit-general-ca-muvinekxd2z1, received after the push and after close; the row is the ledger so the verdict lands here, not on the commit. Confirmed: TICKET_GATE reused unchanged and correlated to t.appKey with actor $1 in the SAME statement; adminAppKeys aggregates only that actor's live admin grants; no count of inaccessible tickets and no request-chosen scope; json_agg ordering and result shape match the old queue; success telemetry derives counts from the returned result and stores no subject, query string, ip or user-agent; replay branches handled separately. The runtime leg audit did not run is covered independently by two parties: my prod call at 17:18:07.275Z and support-evolutiva-cc's at 17:19:07.567Z, each 200 {tickets:[],adminAppKeys:[]} with a matching supportApiListTickets info/success row. STILL uncovered by anyone: the adminAppKeyCount>0 / filteredToNothing=false arm, which no actor can currently produce. Audit accepted the post-hoc Class-A correction - the trigger is a gate predicate APPEARING in added code, not a gate being edited.","reportRef":null}