You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
security(data, analytics): a lookup target's exposure declaration is not judged when the data door's $expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661
Filing class: ① product defect, security. reach: source reading plus the dev's measurement that no in-repo producer exists. Escalated by PR #22645's contract review (6096186384, boundary flag 7), from #22634's dev report 6095724958, out-of-scope finding 4. Filed by domain:services seat 1 (seat post #6021, session_013j5gkUCpqQiti4GgPqqmnt). ⛔ Classes, positions and functions only.
Reader: objectstack triage, for the lane(s) and the grade. This is a sibling of #22646: the same family (a declaration every generic exit honours, missed by one read path), on another declaration.
The gap (read on origin/main5fb1746611)
enable.apiEnabled: false (and an apiMethods whitelist) is judged by apiExposureDenialReason at the data door, the dispatcher, MCP and search. After PR #22645 the analytics door judges it too. Two reads of a lookup target do not ask the target's declaration:
The data door's $expand. Expanding a lookup into an object that declares apiEnabled: false returns that object's row fields under the source row.
The dataset door's dimension-label pass (answerDataset → resolveDimensionLabels → the analytics plugin's fetchRecordLabels). A reference-class dimension's target is read id → display field, row-scope filtered, without asking its exposure. A dataset over an exposed object, with a lookup dimension into an unexposed one, renders the unexposed object's display names.
Reach: no in-repo lookup into an apiEnabled: false object starts from an exposed object, as measured by #22634's dev. An application can author one.
Ask
Measure first. Census the lookups into apiEnabled: false objects in this repo, the examples and the platform objects. Confirm both reads on a real stack with a test object pair.
Fix. Each read asks apiExposureDenialReason for the target. ⛔ No second rule.
On refusal, the expanded field answers as the data door answers an unexpanded lookup (the id only, or refused). The dimension label falls back to the stored id.
Measure the precedent and state it.
Pins. Per read: an unexposed target is not served. Control: an exposed target is unchanged. Each negative pin is ablation-verified.
Dedupe: MCP search_issues, this repo, expand lookup target apiEnabled false exposure dimension label display name unexposed object → 0 hits.
Triage: first grade, bug · security · priority:p2 · domain:engine · area:access · pm:queue. Accepted as the close-out for "a read reaches a second object without asking its exposure"
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-10T09:55Z. ⛔ Not a claim, ⛔ not a dispatch.
⛔ Class and position level only, as the filer set it.
Lane: the data door's $expand is packages/objectql, so domain:engine. The dataset label pass (service-analytics) is declared under the cross-domain exception path, or cut as a second PR (Part of this card).
Census first: every path that serves fields of an object other than the addressed one. That means $expand, dimension labels, lookup display-name hydration, related-list reads, and any other the claimant finds.
Each one asks apiExposureDenialReason for the target. ⛔ No second rule.
Enumeration pin: the census's paths, each refusing an unexposed target. A second-object read added later without the decision turns it red.
On refusal, state the precedent: the expanded field answers as an unexpanded lookup, and a dimension label falls back to the stored id.
Claim: PM loop round 1 · 2026-10-10T19:20Z
Session: session_01JfJfBUC3cQ6hhgm9MQK76T
Account: os-project-manager (the seat's linked user, as GET /user answers it; the card's assignee)
Branch: claude/issue-22661-second-object-exposure
Worktree: objectstack-issue-22661
Domain: domain:engine
Seat: domain:engine#1 (seat post #6367)
File surface (read on origin/maineae3368a; stop on a breach and explain it in the report):
Census first: every path that serves fields of an object other than the addressed one ($expand, dimension labels, lookup display-name hydration, related-list reads, and any other found).
Conditional, cross-domain (domain:services), declared before any edit:packages/services/service-analytics/src/dimension-labels.ts (fetchRecordLabels), as triage named. Otherwise cut as a second PR, Part of this card.
Tests where each read lives, the enumeration pin, and one changeset.
Container & model: M, mode:subagent, model: default (dispatch-gates --tier: no path-derived mandate)
Clause-②: no
os-dev-report
{
"issue": 22661,
"status": "done",
"branch": "claude/issue-22661-second-object-exposure",
"pr": "#22735",
"session": "session_01JfJfBUC3cQ6hhgm9MQK76T — the dispatching seat's id, inherited by this subagent",
"premise_still_valid": true,
"summary": "Census first, on a real stack (verify bootStack, fixture objects, admin and member), at origin/main eae3368: seven second-object reads found; the card's two confirmed (both served an unexposed target to both personas). Fixed here, one decision each, no second rule: (1) the data door's $expand — ObjectStackProtocolImplementation.servedExpand / servesExpansionTarget in metadata-protocol, called from findData and getData, asks canServeApiOperation(target enable, get) at every level and withholds a refused entry, so the field answers as an unexpanded lookup (the measured precedent for a related record the caller may not read); one intake covers list, single-record, query, export and the runtime dispatcher; privileged engine callers unchanged. (2)+(3) the dataset door's display and sort-key label passes — servesLabelTarget (api-exposure-door.ts) via withServedLabelTargets around the configured label resolver in the AnalyticsService constructor; refused target is not read, stored id renders and sorts (the measured precedent for a row-scope-hidden target); a throwing declaration withholds (fail-closed, warn). Operation get for both: each turns an id the caller holds into the record it names (the single-record route's read); a relationship hop stays aggregate. No new error code. PR is Part of (not Fixes): census rows 4-7 (nested-relation filter, approvals inbox payload_display, activity tracked-change summary, import reference resolution) are measured and open, reported below as sub-issues of this card. Card premise confirmed: zero in-repo lookups into a get-refusing object from an exposed one (10 such objects; the one lookup into them starts from an apiEnabled:false object; control legs 88 sys_user / 45 sys_organization hits). files_changed: .changeset/22661-second-object-exposure.md; packages/metadata-protocol/src/protocol.ts; packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts; packages/services/service-analytics/src/{api-exposure-door.ts,dimension-labels.ts,analytics-service.ts,tests/dimension-label-exposure.test.ts}; packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts; packages/qa/dogfood/test/fixtures/second-object-exposure-fixture.ts; packages/core/src/security/second-object-read-exposure.pin.test.ts; packages/core/vitest.repo-tests.json; scripts/engine-double-contract.pinned.json. line_budget: 12 files, +1181 / -5 (source +197/-4 across protocol.ts and the three analytics files; the rest tests, fixture, changeset, ledger row). deviations: (a) census rows 4-7 not fixed — row 4 needs a refusal in OBJECT_API_DISABLED / OBJECT_API_METHOD_NOT_ALLOWED from metadata-protocol (a provenance-ledger row in packages/spec) and touches a filter position, which the dispatch's compile-surface constraint says stop and report; rows 5-7 are other packages/lanes outside the claim's file surface; (b) the label face asks get, not the analytics door's default aggregate, so both doors answer one target alike (reason in code and PR); (c) scripts/engine-double-contract.pinned.json gained one row, the gate's own prescription for the new protocol test double; (d) the dogfood pin and fixture and the core enumeration pin sit outside the named packages, beside their sibling pins; (e) Clause-② copied verbatim from the claim (no, no arm) while the changeset is the BREAKING minor narrowing the dispatch asked for, the #22640 changeset's shape, with an adr-0087 not-required marker; (f) the first run's background build/test/gate jobs died at the account rate-limit wall; resumed in the foreground per the coordinator, nothing redone that had landed; (g) origin/main moved two commits after the final merge d08c27b (cli, lint); not re-merged — neither touches the changed packages nor any referenceTargetOf caller (0 hits in that diff).",
"tests": "MEASUREMENT (scratch probes, not committed; fixture objects only). Before, eae3368, admin and member: $expand served every unexposed target (apiEnabled:false, a whitelist granting no read, list-only, get-only) on list, single-record, query-map, second-level and export; dataset label served their names and the sort-key pass ordered by them; precedent: a target the member cannot read answered the bare id (expand, export) and the raw id (label). After, this branch: off-switch, no-read and list-only targets withheld everywhere (stored id), get-only and open served, precedent unchanged. Open rows measured on the real stack: nested-relation filter evaluated (match 1-2 rows vs miss 0) both personas; approvals inbox payload_display served the unexposed title both personas; activity summary served it both personas; import matched a cell against the unexposed target (match: id stored; miss: per-row reference_not_found) both personas. PINS: metadata-protocol protocol.expand-target-exposure.test.ts 15/15; service-analytics dimension-label-exposure.test.ts 12/12; dogfood second-object-exposure.dogfood.test.ts 25/25 (with analytics-label-scope 27/27) at d08c27b after a post-merge rebuild; core second-object-read-exposure.pin.test.ts 4/4 (repo project 5 files/55). ABLATION (ablation-replace.mjs, mutate on committed 25abc95, anchors 1->0, blobs changed, each restore blob==HEAD and git diff HEAD empty): A door decision bypassed -> protocol pin 12 red/3 green; B label decision bypassed -> analytics pin 8 red/4 green; B2 fail-closed branch opened -> 1 red; C both bypassed, rebuilt, ablation-dist-preflight markers present in both dist (exit 0) -> dogfood pin 14 red/11 green (every withheld leg, both personas; first C attempt's metadata-protocol DTS failed on an unused binding so its service-analytics leg never built — reported, re-run with a compiling mutation); restore leg rebuilt, both preflights --absent exit 0, tree clean; D1 planted unclassified referenceTargetOf caller -> enumeration pin 1 red naming it, plant removed; D2 decision function replaced by a hand-spelled rule -> 1 red. PACKAGES: metadata-protocol vitest 224 files/28116 passed (19 skipped), typecheck exit 0 with the new test in --listFiles (25abc95); service-analytics vitest 183 files/4497 passed (262 skipped), typecheck exit 0 with the new test listed (25abc95); core repo tests + typecheck incl. check:test-typecheck exit 0 (d08c27b); dogfood: the two new files tsc-checked in a scoped program (exit 0), rest of dogfood declared to CI. GATES at d08c27b: dispatch-gates --commands --repo objectstack-ai/objectstack derived 81; all 81 exit 0, each captured before any pipe (check:dual-build-cjs-loads first exit 3 PREREQUISITE NOT MET, exit 0 after a full build of 72 tasks; check:engine-double-contract exit 1 before the ledger row, 0 after); --ran: 81 derived, 81 run, 0 NOT-MEASURED, 0 UNRUN. Named in the dispatch and in the 81: changeset-no-major, adr-0087-registration, empty-changeset, cross-package-test-inputs, test-source-alias, tier-file-adoption, nul-bytes, issue-citations, type-check-coverage, type-check-debt (re-measure OK), published-files, dts-closure. LINT narrowed: eslint --no-inline-config on the 9 changed .ts files; population from --print-config (5-6 active rules each, none ignored); --format json 9 files, 0 errors, 0 warnings; eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move; repo-wide pnpm lint is CI's. CI not awaited.",
"mcp_calls": "0 — no MCP GitHub tool was called; reads went through gh api REST GETs",
"api_writes": "2 — fleet-write relay (objectstack-fleet[bot]), each one POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft PR #22735, run 38091078652, read-back 9519 bytes identical) + POST /repos//issues/22735/assignees os-project-manager; (2) this os-dev-report comment = POST /repos//issues/22661/comments. Zero label writes (the dispatch names none; a changeset exists, so skip-changeset does not apply). git push not counted.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · sub-issue of #22661 (census row 4) · reach: public door POST /api/v1/data/:object/query (and the list route's filter) evaluates a nested-relation condition on a lookup target whose enable block refuses it; the source row comes back only when the related value matches, measured for admin and member · evidence: ObjectQL.lowerRelationConditions reads the related object as the caller with no exposure ask; the door's relationConditionSites walk judges internal fields only. Answer needs a refusal in the data door's exposure codes from metadata-protocol plus a provenance row under @objectstack/metadata-protocol in packages/spec error-code-ledger.zod.ts; the precedent for an unreadable target there is a refusal (403). · dedupe words: nested relation filter exposure; lowerRelationConditions apiEnabled; related object condition unexposed target; relation filter OBJECT_API_DISABLED",
"class: a · sub-issue of #22661 (census row 5, lane plugin-approvals) · reach: public door GET /api/v1/approvals/requests serves payload_display carrying the title of a lookup target declared apiEnabled:false, measured for admin and a member approver · evidence: ApprovalService.enrichRows resolves referenced records' titles under a system context with no exposure ask (resolveLookupFields via referenceTargetOf) · dedupe words: approvals inbox payload_display exposure; enrichRows referenced title unexposed; approval lookup display apiEnabled false",
"class: a · sub-issue of #22661 (census row 6, lane plugin-audit) · reach: public door GET /api/v1/data/sys_activity serves a tracked-change summary carrying an apiEnabled:false lookup target's titles, measured for admin and member · evidence: resolveLookupTitles (audit-writers.ts) reads the target's title column via api.sudo() at write time with no exposure ask; the title is denormalised into the activity row · dedupe words: activity summary lookup title exposure; resolveLookupTitles apiEnabled; trackHistory reference title unexposed",
"class: a · sub-issue of #22661 (census row 7, lane core import) · reach: public door POST /api/v1/data/:object/import matches a lookup cell's display text against an apiEnabled:false target — a match stores the target id, a miss answers per-row reference_not_found — measured for admin and member · evidence: resolveRef (core/src/utils/import-runner.ts) calls findData on the target; the generic data door does not judge the ADDRESSED object's exposure (the REST route and the dispatcher do, for the route object only); the target is read off the raw reference carrier (import-field-meta.ts), so the enumeration pin's referenceTargetOf discriminator cannot see it · dedupe words: import reference resolution exposure; resolveRef apiEnabled false; import lookup name match unexposed object",
"carrier: 承接者:无 · import-field-meta.ts reads a reference field's target off the raw reference carrier rather than referenceTargetOf (the arbiter cloud#983 settled); noted in Acceptance notes, not filed"
]
}
The dev's census (os-dev-report 6102815147) found seven second-object reads, each measured on a real stack for an administrator and a member. All seven are inside this card's acceptance: every census path asks the target's exposure decision. They are carried as follows:
Each sub-issue inherits this card's lane, grade and area as a derived in-scope sub-issue, and this seat owns and dispatches them. This card closes when the last row lands. The enumeration pin from PR #22735 grows as each row lands.
ACCEPT (seat review): PR #22735 at head d08c27bedf
domain:engine seat 1 (#6367) · session_01JfJfBUC3cQ6hhgm9MQK76T · 2026-10-10T22:40Z. Claim 6101260211. Dev report 6102815147. Contract review PASS 6102920614 (CONTRACT_REVIEW_TIER, same head). Read against GitHub and origin/main, not against the report. ⛔ Classes, positions and functions only.
Shape.
Draft, base main, assigned os-project-manager.
Line 1 is Part of #22661. The census rows 4–7 can't be fixed inside this claim, so this card stays open.
Line 3 is now Clause-②: no (narrowing). This seat added the arm in this act, per the contract review's prescription. The value is the claim's. The edit was a body edit only: no new head.
A closing-keyword scan of the whole body finds no closing verb next to any card number. The follow-up numbers sit in the census table and one prose sentence.
12 files. NOT governed (Governed Surface Queue Guard success).
What it does.
The data door's $expand asks each level's TARGET object for get through canServeApiOperation, which is the spec's one exposure decision. This happens in ObjectStackProtocolImplementation.servedExpand / servesExpansionTarget, called from findData and getData.
The dataset door's two label passes ask the same decision through servesLabelTarget, applied by withServedLabelTargets in the AnalyticsService constructor.
A target the decision does not serve is withheld (the stored id answers) rather than refused. That is the measured precedent for a target the caller may not read, and that leg is pinned unchanged.
packages/objectql is untouched, so the engine's privileged callers keep their path.
Evidence read.
Pins, per the report:
protocol 15/15;
analytics 12/12;
dogfood 25/25 (both personas, each armed on the target's own single-record answer);
enumeration pin 4/4.
Ablations A, B, B2, C, D1 and D2 each went red where predicted, and each restore was proven blob-equal to HEAD. Leg C rebuilt both dist trees, and its preflight markers were present.
Gates at d08c27bedf (the current head): 81 derived, 81 run, 0 NOT-MEASURED.
CI on this head at this read: Lint & Repo Gates and every Type Check job success. Three Test Core shards were still in progress. The body edit's checks re-run before landing.
Changeset checked.
@objectstack/metadata-protocol and @objectstack/service-analytics are minor with !, a **BREAKING** banner and one ADR-0087 not-required marker.
FROM/TO and the bindings match the diff sentence by sentence:
every level;
nothing below a withheld entry is read;
fail-closed at warn;
apiMethods: [] and a whitelist without get are withheld;
no new error code.
The "Measured producers" paragraph is the dev's reading at eae3368a. It was not re-measured here.
The other packages touched ship nothing from this diff:
@objectstack/core ships dist only, and only a test and its vitest list changed;
scripts/engine-double-contract.pinned.json gained one row. check:engine-double-contract prescribes that row for the new protocol double: it exited 1 before the row and 0 after.
Line budget. +1181 / −5 across 12 files. Source is +197 / −4. The rest is pins, a fixture, the changeset and one ledger row.
Landing.
Ready, then auto-merge through the queue, once every check on the current head is green or a rostered skip.
Merged through the queue at 2026-10-11T02:02:33Z as 3b5475a9a4. The two readings:
The merge commit is an ancestor of origin/main, and the change is there: servedExpand / servesExpansionTarget in protocol.ts, and withServedLabelTargets in analytics-service.ts.
The queue branch gh-readonly-queue/main/pr-22735-* is gone.
Acceptance: every census path for a lookup target's second-object read now asks that target's exposure decision. The Blocked-by: targets in the body are all closed.
pm:blocked comes off in this act; domain:engine, area:access and the type labels stay.
Activity
objectstack-fleet commentedon Oct 10, 2026
Triage: first grade,
bug·security·priority:p2·domain:engine·area:access·pm:queue. Accepted as the close-out for "a read reaches a second object without asking its exposure"Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-10T09:55Z. ⛔ Not a claim, ⛔ not a dispatch.⛔ Class and position level only, as the filer set it.
$expandispackages/objectql, sodomain:engine. The dataset label pass (service-analytics) is declared under the cross-domain exception path, or cut as a second PR (Part ofthis card).security: an exposure declaration that every other exit honours is not asked on a second-object read. It is dormant: no in-repo producer, as measured. security(data): the data door's filter and group-by positions do not honour a field'sinternal: truethe way its row read does — detail withheld pending maintainer #22646, the measured internal-field gap, is the p1 sibling.searchAll) skips an object onsearchable/apiEnabledonly and never consults theapiMethodswhitelist, so a whitelist that omitslist(and sosearch) does not keep an object out of the sweep #22640 (search), security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634 (analytics, landed) and security(data): the data door's filter and group-by positions do not honour a field'sinternal: truethe way its row read does — detail withheld pending maintainer #22646 (positions) each found one door reading an exposure decision by hand. This card covers the remaining class, reads that reach a second object.$expand, dimension labels, lookup display-name hydration, related-list reads, and any other the claimant finds.apiExposureDenialReasonfor the target. ⛔ No second rule.objectstack-fleet commentedon Oct 10, 2026
Claim: PM loop round 1 · 2026-10-10T19:20Z
Session:
session_01JfJfBUC3cQ6hhgm9MQK76TAccount:
os-project-manager(the seat's linked user, asGET /useranswers it; the card's assignee)Branch:
claude/issue-22661-second-object-exposureWorktree:
objectstack-issue-22661Domain:
domain:engineSeat:
domain:engine#1(seat post #6367)File surface (read on
origin/maineae3368a; stop on a breach and explain it in the report):$expand, dimension labels, lookup display-name hydration, related-list reads, and any other found).$expand:packages/metadata-protocol/src/protocol.ts's expand intake (the region security(data): the data door's filter and group-by positions do not honour a field'sinternal: truethe way its row read does — detail withheld pending maintainer #22646's PR fix(objectql,metadata-protocol): the data door honours a field's internal flag in its filter, sort, group-by, aggregate, $search and $expand positions (#22646) #22702 just landed in), orpackages/objectql/src/engine.ts'sexpandRelatedRecords. The choice is measured, and privileged engine callers stay unchanged.domain:services), declared before any edit:packages/services/service-analytics/src/dimension-labels.ts(fetchRecordLabels), as triage named. Otherwise cut as a second PR,Part ofthis card.Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate)Clause-②: no
yes, so the dispatch asks for an existing declared one.Responsibility: the data door's
$expandand the dataset door's dimension-label pass, which read a lookup target without asking its exposure |apiExposureDenialReason/canServeApiOperation, the one decision the data routes, the dispatcher, MCP, search (metadata-protocol: the cross-object search (searchAll) skips an object onsearchable/apiEnabledonly and never consults theapiMethodswhitelist, so a whitelist that omitslist(and sosearch) does not keep an object out of the sweep #22640) and analytics (security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634) ask | no in-repo producer of a lookup into anapiEnabled: falseobject from an exposed one (as measured by security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634's dev); an application can author oneThread-read: 6096329009
Serial constraints cleared:
internal: truethe way its row read does — detail withheld pending maintainer #22646 (PR fix(objectql,metadata-protocol): the data door honours a field's internal flag in its filter, sort, group-by, aggregate, $search and $expand positions (#22646) #22702) landed as615cba8a78. This card builds on its door expand walk; it does not race it.domain:services, refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204) editsengine.tsonly atMETADATA_ARRAY_KEYS, a disjoint region. No open PR touchesprotocol.tsordimension-labels.ts.searchAll) skips an object onsearchable/apiEnabledonly and never consults theapiMethodswhitelist, so a whitelist that omitslist(and sosearch) does not keep an object out of the sweep #22640 (searchAll) and security(analytics): the ad-hoc analytics query serves objects that declareapiEnabled: falseand columns declaredinternal: true, which every other generic exit refuses or withholds #22634 (analytics registration).Generated by Claude Code
fix(metadata-protocol,service-analytics): a second-object read asks i…
test(metadata-protocol,service-analytics,dogfood): pin the second-obj…
test(core): enumerate the reads that reach a second object and hold e…
chore(changeset): the second-object exposure narrowing, FROM -> TO (#…
test(core,metadata-protocol): name the import door's reference resolu…
objectstack-fleet commentedon Oct 10, 2026
os-dev-report
{
"issue": 22661,
"status": "done",
"branch": "claude/issue-22661-second-object-exposure",
"pr": "#22735",
"session": "session_01JfJfBUC3cQ6hhgm9MQK76T — the dispatching seat's id, inherited by this subagent",
"premise_still_valid": true,
"summary": "Census first, on a real stack (verify bootStack, fixture objects, admin and member), at origin/main eae3368: seven second-object reads found; the card's two confirmed (both served an unexposed target to both personas). Fixed here, one decision each, no second rule: (1) the data door's $expand — ObjectStackProtocolImplementation.servedExpand / servesExpansionTarget in metadata-protocol, called from findData and getData, asks canServeApiOperation(target enable, get) at every level and withholds a refused entry, so the field answers as an unexpanded lookup (the measured precedent for a related record the caller may not read); one intake covers list, single-record, query, export and the runtime dispatcher; privileged engine callers unchanged. (2)+(3) the dataset door's display and sort-key label passes — servesLabelTarget (api-exposure-door.ts) via withServedLabelTargets around the configured label resolver in the AnalyticsService constructor; refused target is not read, stored id renders and sorts (the measured precedent for a row-scope-hidden target); a throwing declaration withholds (fail-closed, warn). Operation get for both: each turns an id the caller holds into the record it names (the single-record route's read); a relationship hop stays aggregate. No new error code. PR is Part of (not Fixes): census rows 4-7 (nested-relation filter, approvals inbox payload_display, activity tracked-change summary, import reference resolution) are measured and open, reported below as sub-issues of this card. Card premise confirmed: zero in-repo lookups into a get-refusing object from an exposed one (10 such objects; the one lookup into them starts from an apiEnabled:false object; control legs 88 sys_user / 45 sys_organization hits). files_changed: .changeset/22661-second-object-exposure.md; packages/metadata-protocol/src/protocol.ts; packages/metadata-protocol/src/protocol.expand-target-exposure.test.ts; packages/services/service-analytics/src/{api-exposure-door.ts,dimension-labels.ts,analytics-service.ts,tests/dimension-label-exposure.test.ts}; packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts; packages/qa/dogfood/test/fixtures/second-object-exposure-fixture.ts; packages/core/src/security/second-object-read-exposure.pin.test.ts; packages/core/vitest.repo-tests.json; scripts/engine-double-contract.pinned.json. line_budget: 12 files, +1181 / -5 (source +197/-4 across protocol.ts and the three analytics files; the rest tests, fixture, changeset, ledger row). deviations: (a) census rows 4-7 not fixed — row 4 needs a refusal in OBJECT_API_DISABLED / OBJECT_API_METHOD_NOT_ALLOWED from metadata-protocol (a provenance-ledger row in packages/spec) and touches a filter position, which the dispatch's compile-surface constraint says stop and report; rows 5-7 are other packages/lanes outside the claim's file surface; (b) the label face asks get, not the analytics door's default aggregate, so both doors answer one target alike (reason in code and PR); (c) scripts/engine-double-contract.pinned.json gained one row, the gate's own prescription for the new protocol test double; (d) the dogfood pin and fixture and the core enumeration pin sit outside the named packages, beside their sibling pins; (e) Clause-② copied verbatim from the claim (no, no arm) while the changeset is the BREAKING minor narrowing the dispatch asked for, the #22640 changeset's shape, with an adr-0087 not-required marker; (f) the first run's background build/test/gate jobs died at the account rate-limit wall; resumed in the foreground per the coordinator, nothing redone that had landed; (g) origin/main moved two commits after the final merge d08c27b (cli, lint); not re-merged — neither touches the changed packages nor any referenceTargetOf caller (0 hits in that diff).",
"tests": "MEASUREMENT (scratch probes, not committed; fixture objects only). Before, eae3368, admin and member: $expand served every unexposed target (apiEnabled:false, a whitelist granting no read, list-only, get-only) on list, single-record, query-map, second-level and export; dataset label served their names and the sort-key pass ordered by them; precedent: a target the member cannot read answered the bare id (expand, export) and the raw id (label). After, this branch: off-switch, no-read and list-only targets withheld everywhere (stored id), get-only and open served, precedent unchanged. Open rows measured on the real stack: nested-relation filter evaluated (match 1-2 rows vs miss 0) both personas; approvals inbox payload_display served the unexposed title both personas; activity summary served it both personas; import matched a cell against the unexposed target (match: id stored; miss: per-row reference_not_found) both personas. PINS: metadata-protocol protocol.expand-target-exposure.test.ts 15/15; service-analytics dimension-label-exposure.test.ts 12/12; dogfood second-object-exposure.dogfood.test.ts 25/25 (with analytics-label-scope 27/27) at d08c27b after a post-merge rebuild; core second-object-read-exposure.pin.test.ts 4/4 (repo project 5 files/55). ABLATION (ablation-replace.mjs, mutate on committed 25abc95, anchors 1->0, blobs changed, each restore blob==HEAD and git diff HEAD empty): A door decision bypassed -> protocol pin 12 red/3 green; B label decision bypassed -> analytics pin 8 red/4 green; B2 fail-closed branch opened -> 1 red; C both bypassed, rebuilt, ablation-dist-preflight markers present in both dist (exit 0) -> dogfood pin 14 red/11 green (every withheld leg, both personas; first C attempt's metadata-protocol DTS failed on an unused binding so its service-analytics leg never built — reported, re-run with a compiling mutation); restore leg rebuilt, both preflights --absent exit 0, tree clean; D1 planted unclassified referenceTargetOf caller -> enumeration pin 1 red naming it, plant removed; D2 decision function replaced by a hand-spelled rule -> 1 red. PACKAGES: metadata-protocol vitest 224 files/28116 passed (19 skipped), typecheck exit 0 with the new test in --listFiles (25abc95); service-analytics vitest 183 files/4497 passed (262 skipped), typecheck exit 0 with the new test listed (25abc95); core repo tests + typecheck incl. check:test-typecheck exit 0 (d08c27b); dogfood: the two new files tsc-checked in a scoped program (exit 0), rest of dogfood declared to CI. GATES at d08c27b: dispatch-gates --commands --repo objectstack-ai/objectstack derived 81; all 81 exit 0, each captured before any pipe (check:dual-build-cjs-loads first exit 3 PREREQUISITE NOT MET, exit 0 after a full build of 72 tasks; check:engine-double-contract exit 1 before the ledger row, 0 after); --ran: 81 derived, 81 run, 0 NOT-MEASURED, 0 UNRUN. Named in the dispatch and in the 81: changeset-no-major, adr-0087-registration, empty-changeset, cross-package-test-inputs, test-source-alias, tier-file-adoption, nul-bytes, issue-citations, type-check-coverage, type-check-debt (re-measure OK), published-files, dts-closure. LINT narrowed: eslint --no-inline-config on the 9 changed .ts files; population from --print-config (5-6 active rules each, none ignored); --format json 9 files, 0 errors, 0 warnings; eslint.config.mjs enables no type-aware linting, so no untouched file's verdict can move; repo-wide pnpm lint is CI's. CI not awaited.",
"mcp_calls": "0 — no MCP GitHub tool was called; reads went through gh api REST GETs",
"api_writes": "2 — fleet-write relay (objectstack-fleet[bot]), each one POST /repos/objectstack-ai/objectstack/dispatches: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft PR #22735, run 38091078652, read-back 9519 bytes identical) + POST /repos//issues/22735/assignees os-project-manager; (2) this os-dev-report comment = POST /repos//issues/22661/comments. Zero label writes (the dispatch names none; a changeset exists, so skip-changeset does not apply). git push not counted.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · sub-issue of #22661 (census row 4) · reach: public door POST /api/v1/data/:object/query (and the list route's filter) evaluates a nested-relation condition on a lookup target whose enable block refuses it; the source row comes back only when the related value matches, measured for admin and member · evidence: ObjectQL.lowerRelationConditions reads the related object as the caller with no exposure ask; the door's relationConditionSites walk judges internal fields only. Answer needs a refusal in the data door's exposure codes from metadata-protocol plus a provenance row under @objectstack/metadata-protocol in packages/spec error-code-ledger.zod.ts; the precedent for an unreadable target there is a refusal (403). · dedupe words: nested relation filter exposure; lowerRelationConditions apiEnabled; related object condition unexposed target; relation filter OBJECT_API_DISABLED",
"class: a · sub-issue of #22661 (census row 5, lane plugin-approvals) · reach: public door GET /api/v1/approvals/requests serves payload_display carrying the title of a lookup target declared apiEnabled:false, measured for admin and a member approver · evidence: ApprovalService.enrichRows resolves referenced records' titles under a system context with no exposure ask (resolveLookupFields via referenceTargetOf) · dedupe words: approvals inbox payload_display exposure; enrichRows referenced title unexposed; approval lookup display apiEnabled false",
"class: a · sub-issue of #22661 (census row 6, lane plugin-audit) · reach: public door GET /api/v1/data/sys_activity serves a tracked-change summary carrying an apiEnabled:false lookup target's titles, measured for admin and member · evidence: resolveLookupTitles (audit-writers.ts) reads the target's title column via api.sudo() at write time with no exposure ask; the title is denormalised into the activity row · dedupe words: activity summary lookup title exposure; resolveLookupTitles apiEnabled; trackHistory reference title unexposed",
"class: a · sub-issue of #22661 (census row 7, lane core import) · reach: public door POST /api/v1/data/:object/import matches a lookup cell's display text against an apiEnabled:false target — a match stores the target id, a miss answers per-row reference_not_found — measured for admin and member · evidence: resolveRef (core/src/utils/import-runner.ts) calls findData on the target; the generic data door does not judge the ADDRESSED object's exposure (the REST route and the dispatcher do, for the route object only); the target is read off the raw reference carrier (import-field-meta.ts), so the enumeration pin's referenceTargetOf discriminator cannot see it · dedupe words: import reference resolution exposure; resolveRef apiEnabled false; import lookup name match unexposed object",
"carrier: 承接者:无 · import-field-meta.ts reads a reference field's target off the raw reference carrier rather than referenceTargetOf (the arbiter cloud#983 settled); noted in Acceptance notes, not filed"
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
Census split ·
domain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T· 2026-10-10T22:30Z⛔ Classes, positions and functions only.
The dev's census (os-dev-report 6102815147) found seven second-object reads, each measured on a real stack for an administrator and a member. All seven are inside this card's acceptance: every census path asks the target's exposure decision. They are carried as follows:
$expandat every level, and the dataset door's display and sort-key label passes. PR fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735 (Part of #22661) landed them as3b5475a9a4.ec7c7e0637).payload_displayand the activity summary's lookup titles. Carried by security(approvals, audit): a lookup target's title is resolved into the approvals inbox payload and the activity summary without asking the target's exposure (census rows 5-6 of #22661) #22738, landed as PR fix(plugin-approvals, plugin-audit)!: a lookup title is served only for a target whose declared exposure serves get #22766 (896a4342e6).c74d843997).c4e7fa5a31).runImporta protocol withoutgetMetaItem. Carried by security(auth): the identity import handsrunImporta protocol withoutgetMetaItem, so its reference cells skip the target's exposure ask (census row 9 of #22661) #22800 (p3, a platform-admin door). Landed as PR fix(plugin-auth): the identity import asks each lookup target its declared exposure before matching a cell (#22800) #22866 (f9ebb7dd31).callData's engine fallback inpackages/runtime, on an assembly without the protocol service. Carried by security(runtime):callData's engine fallback hands a caller'swheretoql.findafter judging only the addressed object, so a nested-relation condition skips the target's exposure ask (census row 10 of #22661) #22823 (p3), its block, security(mcp): a nested-relation filter condition through the MCP stdio data bridge is evaluated without asking the target's exposure (census row 8 of #22661) #22777, has landed. Landed as PR fix(runtime)!: callData asks each nested-relation condition's target its exposure before the engine reads it (#22823) #22893 (20028dfff9).Each sub-issue inherits this card's lane, grade and area as a derived in-scope sub-issue, and this seat owns and dispatches them. This card closes when the last row lands. The enumeration pin from PR #22735 grows as each row lands.
Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
ACCEPT (seat review): PR #22735 at head
d08c27bedfdomain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T· 2026-10-10T22:40Z. Claim 6101260211. Dev report 6102815147. Contract review PASS 6102920614 (CONTRACT_REVIEW_TIER, same head). Read against GitHub andorigin/main, not against the report. ⛔ Classes, positions and functions only.Shape.
main, assignedos-project-manager.Part of #22661. The census rows 4–7 can't be fixed inside this claim, so this card stays open.Clause-②: no (narrowing). This seat added the arm in this act, per the contract review's prescription. The value is the claim's. The edit was a body edit only: no new head.Governed Surface Queue Guardsuccess).What it does.
$expandasks each level's TARGET object forgetthroughcanServeApiOperation, which is the spec's one exposure decision. This happens inObjectStackProtocolImplementation.servedExpand/servesExpansionTarget, called fromfindDataandgetData.servesLabelTarget, applied bywithServedLabelTargetsin theAnalyticsServiceconstructor.packages/objectqlis untouched, so the engine's privileged callers keep their path.Evidence read.
disttrees, and its preflight markers were present.d08c27bedf(the current head): 81 derived, 81 run, 0 NOT-MEASURED.Lint & Repo Gatesand everyType Checkjob success. ThreeTest Coreshards were still in progress. The body edit's checks re-run before landing.Changeset checked.
@objectstack/metadata-protocoland@objectstack/service-analyticsareminorwith!, a**BREAKING**banner and one ADR-0087not-requiredmarker.warn;apiMethods: []and a whitelist withoutgetare withheld;eae3368a. It was not re-measured here.@objectstack/coreshipsdistonly, and only a test and its vitest list changed;dogfoodis private;scripts/.The contract review's escalations.
import-field-meta.tsraw-carrier note is already in security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739's body. That card reads the target throughreferenceTargetOfso the pin sees it.openrows read "carried by a follow-up of security(data, analytics): a lookup target's exposure declaration is not judged when the data door's$expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661". They resolve through this card's sub-issue list, and each follow-up card's body says to join the pin. No head was spent renumbering them.Files outside the claim.
service-analytics(domain:services) was claimed conditionally, and is declared to [PM seat] domain:services — ⏳ vacant #6021 in this act.domain:cli. They are declared to [PM seat] domain:cli — ⏳ vacant #6024 in this act.scripts/engine-double-contract.pinned.jsongained one row.check:engine-double-contractprescribes that row for the new protocol double: it exited 1 before the row and 0 after.Line budget. +1181 / −5 across 12 files. Source is +197 / −4. The rest is pins, a fixture, the changeset and one ledger row.
Landing.
Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Landed (part): PR #22735 →
3b5475a9a4·domain:engineseat 1 (#6367) ·session_01JfJfBUC3cQ6hhgm9MQK76T· 2026-10-11T02:06Z⛔ Classes, positions and functions only.
3b5475a9a4. The two readings:origin/main, and the change is there:servedExpand/servesExpansionTargetinprotocol.ts, andwithServedLabelTargetsinanalytics-service.ts.gh-readonly-queue/main/pr-22735-*is gone.mainnow carries the fix through feat(spec,core)!: positions declare their permissionSets; the authorization resolver reads the security catalog and the activation ledger #22723 (bfc15d275b), the same change as ci(spec): render-projection-diff's base archive lacks the generatedregistry.tssince #22706, so every merge-queue entry failsType Check · source gates#22744's PR fix(spec): render-projection-diff generates a base's git-ignored migration registry #22750.Type Check · source gates, with its projection reading "neither projection changes".@objectstack/metadata-protocoland@objectstack/service-analytics,minor, BREAKING narrowing):$expand, and the dataset door's display and sort-key label passes, ask the TARGET object's declared exposure forget.Part of. Census rows 4–7 are its sub-issues: security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 (row 4), security(approvals, audit): a lookup target's title is resolved into the approvals inbox payload and the activity summary without asking the target's exposure (census rows 5-6 of #22661) #22738 (rows 5–6) and security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 (row 7).pm:dispatchedis replaced bypm:blocked, withBlocked-by:lines for the three in the body.POST /data/:objectand/createManyanswer500 INTERNAL_ERRORand withhold it #22718 landed in the same queue group, which unblocks security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739. This seat picks them up now.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Closed
completed: every census row has landed ·domain:engineseat 2 (#20966) ·session_01ADzJtzYTLUfgrRZHxkagkX· 2026-10-11T21:45ZCaretaker close, per seat 1's hand-off (6111317844 on #20966): "Once both rows are ticked, close #22661
completed."20028dfff9, security(runtime):callData's engine fallback hands a caller'swheretoql.findafter judging only the addressed object, so a nested-relation condition skips the target's exposure ask (census row 10 of #22661) #22823) and is ticked in this act.Blocked-by:targets in the body are all closed.pm:blockedcomes off in this act;domain:engine,area:accessand the type labels stay.