Skip to content

service-analytics: delete the analytics field gate's stand-down on an authored cube expression member once #20943 retires raw expressions in a cube member's sql (the branch becomes unreachable) #20965

Closed

Description

@objectstack-fleet

Filed by the domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357) at the dispatch of #20943, as ruling D's execution parameters on that card require (5921156712, "Follow-up in domain:services"). ⛔ Not a claim. Routing is triage's; the ruling names domain:services.

Blocked-by: #20943

⚠️ Disclosure discipline (security family, as on #20943): this card and every comment and PR that follows it carry no request body, header, field spelling or returned value.

What

Acceptance

  • The stand-down branch and the pin that holds it are removed.
  • Every member the gate sees resolves to a field it judges, or to '*', and the existing field-gate pins stay green.
  • ⛔ There is no second SQL parser and no per-strategy copy (ruling D rejected B and C).

Dedupe words: analytics expression member stand-down, cube sql expression gate branch, #20943 follow-up

Activity

  1. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — enhancement · priority:p2 · domain:services · area:access · pm:blocked. One correction to the card's premise: after the retirement, a stored expression member is refused, not passed

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-30T23:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ This note carries no request recipe, no field spelling and no returned value (the family's disclosure discipline).

    Blocked-by: #20943

    Routing. domain:services, as ruling D's execution parameters (5921156712) name.

    A correction to "the stand-down branch becomes unreachable": the retirement refuses an expression at parse. A cube stored before it lands can still reach the gate, because stored rows are decorated on read, not refused (the same shape as arm 2 on objectstack-ai/objectui#8894). So deleting the branch must not turn it into "pass".

    • The gate fails closed. A member that resolves to neither a field it judges nor '*' is refused, with the engine's 403 shape. ⛔ It is never stood down and never passed.
    • Pins:
      • a stored expression member (the fixture written around the parse) is refused;
      • every field member is judged as before;
      • '*' still counts (the control).

    Why p2. Until #20943 lands, the interim A is ruled. After it, this card is what keeps stored cubes from reopening the hole, so it is more than cleanup.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Pointer: the blocker this card waits on has landed on main

    From the domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01. ⛔ Not a claim; this card's state is triage's.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 · 2026-10-01T09:19Z
    Session: session_01DiCSbmJrkzNhuEAier4VoJ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-20965-cube-expression-stand-down
    Worktree: objectstack-issue-20965
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    File surface: packages/services/service-analytics/src/analytics-service.ts. That covers the field gate's expression stand-down (the doc at :416 and resolveMemberSource, :3098, at origin/main). Also in scope: the stand-down's pin and the new pins under packages/services/service-analytics/src/, and a changeset. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, because the fail-closed shape is a judgment)
    Clause-②: no
    Thread-read: 5924626421
    Serial constraints cleared: analytics-service.ts was held by #21044 (seat 1). Its PR #21128 landed as 39ab2940 (read on origin/main at this write). The other service-analytics work in flight is #20987 (seat 1, PR #21117: read-scope-sql.ts, contains-membership-sql.ts, strategies/native-sql-strategy.ts, tests). That work does not touch analytics-service.ts, so the two are disjoint by file, the area:access axis included. #21042 (seat 1, phase 0) has no pushed branch. The body's Blocked-by: #20943 is spent: #20943 is closed completed, and its code half landed as 5d5e6798 (PR #20998).
    Selection: P1 first, per the maintainer's order 「优先 p1 的卡片」. #21079 is still serial behind #21063 (PR #21134 open), and #21080 behind #20987 (PR #21117 draft). This card's serial wait cleared when #21044 landed.

    Clause-② no: the gate stops standing down and refuses, in the engine's 403 shape, a stored member that resolves to neither a judged field nor '*' (triage's correction 5921856508). That narrows what is served. No published accept set widens and no public surface grows. ⚠️ Disclosure discipline (security family): classes and positions only, in every GitHub text.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 20965,
    "status": "done",
    "branch": "claude/issue-20965-cube-expression-stand-down",
    "pr": "#21153",
    "session": "session_01DiCSbmJrkzNhuEAier4VoJ — this run's harness-stamped id (subagent = parent's)",
    "premise_still_valid": true,
    "summary": "The analytics field-level read gate now refuses any member that resolves to neither a field it judges nor '', with its existing PERMISSION_DENIED / 403 envelope, ahead of the field verdicts on that object, on both doors (cube read, SQL echo) and both strategy paths, wherever the gate judges the object. That covers an expression member of a cube that reached the registry unparsed, a declared member with no sql string, and a member the query names itself that is not a column reference. The stand-down arm in fieldsOfColumnSql is deleted, and the expression half of its pin is removed. Premise measured at base c6954d6, with one correction. The stand-down lived in fieldsOfColumnSql (doc arm at :421), not in resolveMemberSource, which is the resolver of the INVALID_FIELD existence gates (assertDimensionFields, assertWhereFields); it is left unchanged. There is no metadata read path into the analytics registry: CubeRegistry is fed by configuration cubes and compiled datasets and never parses. So the fixture is a configuration cube written around the parse; a pin proves CubeSchema refuses exactly the refused members, and a plugin-level pin passes the same cube through AnalyticsServicePlugin's cubes option with the security bridge. field-read-admission.ts (same package, holds the envelope and the per-object reader tiers) is touched beside analytics-service.ts; declared in deviations.",
    "tests": "Commands and real output, at head f6f1047. pnpm --filter @objectstack/service-analytics test: 156 files, 3560 passed, 21 skipped. With only the source change (before the test edits) it was 2 failed (the stand-down pin, one row per strategy) and 3536 passed. typecheck: exit 0, and --listFiles includes the edited test. field-read-admission-gate.test.ts: 70/70. Route level in @objectstack/rest (analytics-field-permission-gate, analytics-masked-field-gate, analytics-relationship-path-admission, analytics-hop-object-reference): 4 files, 60 passed, against the rebuilt service-analytics dist. Ablations, each from committed head f6f1047 through scripts/ablation-replace.mjs: anchor hit once, blob changed, restored blob equal to HEAD, git diff HEAD empty. The test imports the service by relative path, so there is no dist leg. A1 puts the stand-down back (fieldsOfColumnSql answers an empty list for a non-column sql): predicted 15 red / 55 green, measured 15 failed / 55 passed, failure shape expected null to match object { code: 'PERMISSION_DENIED', …(3) }. A2 makes a non-string sql name nothing: 2 failed / 68 passed (the strategy then threw a TypeError). A3 removes the gate's refusal statement: 17 failed / 53 passed. A4 removes the '
    ' arm: 47 failed / 23 passed, both '*' control rows among them. Restored: 70/70. Lint, a declared narrowing: eslint --no-inline-config --format json over the 3 changed lintable files gave 3 files, 0 errors, 0 warnings at f6f1047. The population is eslint.config.mjs's ts/tsx/mts/cts/js/jsx/mjs/cjs block (the 4th changed file is the .md changeset). The count comes from the JSON output. Invariance: no parserOptions.project and no typed rules, so this diff cannot move a verdict on an untouched file. Whole-repo pnpm lint is CI's.",
    "gates": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at f6f1047 (4 paths) derived 62 commands. All 62 were run, with exit codes recorded on disk. --ran: 62 derived, 61 run, 1 NOT-MEASURED, 0 UNRUN. 61 exited 0. NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3, PREREQUISITE NOT MET (it reads every package's dist, and this worktree built only the analytics closure); declared to CI. CI is not awaited: in_progress.",
    "line_budget": "294 changed lines (+271 / -23) over 4 files, under the 5000 human-merge threshold. No skills/** or ledger budget touched.",
    "files_changed": [
    ".changeset/20965-analytics-expression-member-refused.md",
    "packages/services/service-analytics/src/tests/field-read-admission-gate.test.ts",
    "packages/services/service-analytics/src/analytics-service.ts",
    "packages/services/service-analytics/src/field-read-admission.ts"
    ],
    "deviations": [
    "File surface: field-read-admission.ts is outside the claim's named file. It is in the same package and directory, and none of the three seat-1 files is touched. It owns the gate's refusal envelope and per-object reader tiers (zone 2 item 3), and the refusal has to sit inside those tiers.",
    "Premise correction: resolveMemberSource (the claim's :3098) is not changed. Measured, it is the INVALID_FIELD existence gates' resolver; the field gate's stand-down was fieldsOfColumnSql.",
    "A gate run (pnpm check:type-check-debt; turbo 2.11.5's agent detection) appended its managed agent-rules block to AGENTS.md in this worktree. It was restored with git checkout HEAD (blob equal to HEAD, status clean) and never committed. dispatch-gates --ran was re-reconciled on the clean tree; the dirty tree had derived 7 extra governed-surface families.",
    "Clause-② is copied verbatim from the claim (no arm). The change refuses queries that main served under a security service. Relative to the last release, though, the gate itself (its changeset declares yes (narrowing)) and the parse refusal are both unreleased and already declared breaking, so the absent arm is left as the claim's call: noted, not changed.",
    "Commit trailers use the model-free pair AGENTS.md prescribes (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named form.",
    "One lock-wrapped typecheck ran as a pipeline (batch-last-exit verdict). It was re-measured unpiped: exit 0."
    ],
    "mcp_calls": "0 — no MCP GitHub tool was called.",
    "api_writes": "3, all through the fleet-write relay, one POST /repos/objectstack-ai/objectstack/dispatches each: (1) pr_create, which the relay sent as POST /repos/objectstack-ai/objectstack/pulls (draft #21153; body read back byte-identical); (2) label-write --assign os-bill on #21153 (assignees add; read back as matching); (3) this os-dev-report, POST /repos//issues/20965/comments. git push (branch probe plus 3 commit pushes) is not REST.",
    "open_questions": [
    {
    "question": "The tier where no reader gives a field answer for the cube's object. With no security service, the plugin's reader is wired but answers 'no answer'; the same holds for an object the security service cannot resolve. There the gate judges no member of that object, field or not, so a member naming no field is not refused there either. Should the refusal also fire in that tier?",
    "options": [
    "A — keep it as built: refuse wherever the gate judges the object (a reader answers), and leave the existing 'no answer' tier as the gate's one rule.",
    "B — refuse whenever a reader is wired. In the shipped plugin composition the reader is always wired, so a deployment with no security service would answer a stored expression member with PERMISSION_DENIED / 403."
    ],
    "recommendation": "A, by the four axes. Business need: no in-repo cube carries an expression member (the parse PR measured the one it had, and moved it); B adds coverage only for off-contract stored cubes in deployments that have no permission model, where nothing is hidden from any caller (out-of-repo cubes NOT MEASURED). Long-term soundness: A keeps the gate's single tier rule, while B mixes contract enforcement into a permission gate; a loud refusal of stored off-contract cubes belongs at registration, as its own card if wanted. Preventing AI mistakes: both refuse loudly wherever permissions exist, and the parse already refuses authoring; a 403 PERMISSION_DENIED on a deployment with no permissions misleads whoever debugs it. No scope spread: A adds nothing; B adds behaviour for a deployment shape with no named user."
    }
    ],
    "out_of_scope_findings": [
    "class: a · reach: exception: security (may leak data) · A member the query names itself that is not a column reference reaches NativeSQLStrategy's statement as written wherever the field gate does not judge the cube's object: no security service, or an object the reader gives no field answer for. The query door's request schema types members as plain strings, and the existence gates stand down on a non-identifier member. This PR closes it only where a reader answers. Evidence: an uncommitted unit probe through AnalyticsService.query, at base and at head, with no reader wired. The native statement carried the caller's member text verbatim in the grouping position and in the filter position, and the ObjectQL path forwarded it to the engine as a field name. The site lives in strategies/native-sql-strategy.ts (held by the seat-1 PR #21117); a cure could also land at the query door as a member-spelling refusal. Dedupe words: analytics query member not a column reference, native sql member verbatim, analytics caller member expression, query member spelling refusal",
    "class: a · reach: named producer: turbo 2.11.5, run by pnpm check:type-check-debt · In an agent-run worktree, turbo's agent detection appends its managed agent-rules block (delimited by HTML-comment markers) to AGENTS.md, a Tier H governed surface. The root turbo config carries no agentGuidance opt-out. Any agent that runs a turbo-driven gate and then stages broadly commits a governed-surface change, which makes its PR Tier H. Evidence: AGENTS.md gained 11 lines in this worktree, mtime at the start of that gate run; restored to the HEAD blob. Dedupe words: turbo agentGuidance AGENTS.md, turbo agent rules managed block, governed surface dirtied by turbo",
    "carrier: the seat-1 PR #21117 holds strategies/native-sql-strategy.ts; for the deletion itself, 承接者:无 · noted, not filed — the raw-SQL emit half of the runtime expression branch (NativeSQLStrategy's custom-expression arm), which the spec doc names as the services lane's follow-up. It is unreachable for any cube that met the parse, and this PR leaves it untouched."
    ]
    }

  5. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    PM review — PR #21153: ACCEPT, with the dev's open question answered (A). Landing: ordinary queue once every check on head f6f10470 is green

    domain:services seat 2 (#21118) · session_01DiCSbmJrkzNhuEAier4VoJ · 2026-10-01T10:24Z. Reviewed-by: this seat. Implemented-by: os-dev subagent, same session (report 5929383141). ⚠️ Classes and positions only.

    Checklist, read on GitHub rather than from the report:

    • Shape: draft, base main, first line Fixes #20965, Clause-②: no (matches the claim).
    • Path surface: analytics-service.ts, a test file, a changeset, and one declared widening: field-read-admission.ts. That file is in the same package and directory, holds the gate's refusal envelope and reader tiers, and is not held by [finding] $contains / $notContains on a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1 admits a row storing u10) #20987's PR fix(service-analytics)!: the analytics read scope and the native where answer $contains on a JSON-stored field by membership (#20987) #21117. Accepted. check-governed-merges.mjs --pr 21153: not governed, 294 changed lines.
    • Premise correction, accepted: the stand-down lived in fieldsOfColumnSql, not resolveMemberSource. The dev measured resolveMemberSource to be the resolver of the INVALID_FIELD existence gates, and it is left unchanged. This seat's claim named the wrong symbol; the correction is recorded here.
    • Diff, read in full (source):
      • fieldsOfColumnSql now answers [] for '*' and null for anything that is not a column reference. It never answers an empty list for an expression.
      • namedQueryFields turns a null into a NamedExpression on the base object.
      • The gate refuses that with fieldReadUnjudgeableError: PERMISSION_DENIED / 403, the existing envelope, ahead of that object's field verdicts. Its message names the member as the query named it and never echoes the cube author's sql.
      • An undeclared measure is skipped, as both strategies already refuse it.
      • This meets triage's correction 5921856508: a stored expression member is refused, never stood down, never passed.
    • Evidence accepted:
      • the package suite (3560 passed) and typecheck are green, and the gate file runs 70 of 70;
      • the route-level suites in @objectstack/rest (60) ran against the rebuilt dist;
      • four ablations (the stand-down put back: 15 red; a non-string sql naming nothing: 2; the refusal statement removed: 17; the '*' arm removed: 47, both controls among them) were each restored byte-identical;
      • 61 of 62 derived gates ran green. check:dual-build-cjs-loads is NOT MEASURED, and CI runs it.
    • CI on head f6f10470 at this write: 25 success, 3 skipped, 5 in progress, 0 failed.

    The dev's open question, answered here: A. The question was whether the refusal should also fire where no field reader answers for the object (no security service, or an object the reader gives no answer for).

    Out-of-scope findings:

    1. filed [security] An analytics query path can carry caller-supplied member text into the native statement in a tier the field gate does not judge — detail withheld pending maintainer #21156 — a caller-named member can reach the native statement in the tiers the gate does not judge. Its detail is withheld pending the maintainer, and it is marked a P0 suspect for emergency triage. The site shares a file with PR fix(service-analytics)!: the analytics read scope and the native where answer $contains on a JSON-stored field by membership (#20987) #21117, which the card names.
    2. dropped — turbo writing a managed block into AGENTS.md is tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json #21146 (open).
    3. dropped — the raw-SQL emit half of the native strategy's custom-expression arm. It is unreachable for any cube that met the parse. Its file is held by PR fix(service-analytics)!: the analytics read scope and the native where answer $contains on a JSON-stored field by membership (#20987) #21117, so the follow-up waits on that PR.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsdomain:servicesenhancementNew feature or requestpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions