Skip to content

docs(spec): fileAccessDelegate and the refused file marker name the record-read verdict beside the download (#22698) - #22713

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22698-file-delegate-metadata-text
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22698-file-delegate-metadata-text

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22698
Clause-②: no

Wording only. No key, type, schema shape or runtime code changes. Dispatched by the PM loop (round 1, domain:spec seat 1), session session_01S3aAf11JjbW1mSGL1EhfFj, branch claude/issue-22698-file-delegate-metadata-text.

What changes

PR #22697 (#22637) made a record read follow the download door's field-owned verdict when the reader's own sys_file read is refused. The texts an author reads about fileAccessDelegate and the refused marker still described the download door only. This PR names the record-read verdict beside the download verdict:

Text Before After
fileAccessDelegate .describe() (object.zod.ts), which feeds the JSON Schema and three reference pages "authorizes downloads of files owned by this object's media fields, instead of testing ..." "authorizes downloads of files owned by this object's media fields, and decides whether a reader who may not read sys_file sees their name, size and type in a record read of this object (asked once per owning record on each such read), instead of testing ..."
fileAccessDelegate form helpText (object.form.ts), plus its en / zh-CN / ja-JP / es-ES bundle values same sentence, no example the same addition
fileAccessDelegate TSDoc (object.zod.ts) "may this caller download ...", "denies the download" the two paths that ask the delegate: download and record read. On each path it states when the delegate is asked and what a refused reader receives. Fails closed on both.
FileRefusedValueSchema TSDoc (field-value.zod.ts) "Nothing else is served, because nothing else was read" names the owned-file case: the system-context row read and the field-owned verdict. For a refused reader the marker means the record does not own the file, the verdict refused the owning record, or the verdict could not be asked.
metadataRefused .describe() (field-value.zod.ts), which feeds references/data/field-value.mdx "(no read on sys_file)" "(no read on sys_file, and the record being read does not own the file or its download verdict did not allow it)"
IFileAccessDelegate docblock and authorizeFileRead doc (packages/spec/src/contracts/storage-service.ts), the implementer's text (patch round 1) "May this caller download a file owned by recordId", "widens who can reach the bytes" one verdict answers two questions: the download, and whether a reader refused sys_file sees the file's name, size and type in a record read (asked once per owning record per such read); the data-leak warning covers the metadata too; the fail-closed sentence is kept

Each sentence, read off the code at d8830c2805

  • When the delegate is asked on a record read. ObjectQL.resolveFileReferences (packages/objectql/src/engine.ts) calls readParentDerivedFiles only in the isReadRefusal(error) arm, which is the caller's own sys_file read answering PERMISSION_DENIED. A caller whose sys_file read succeeds hydrates from that read and never reaches the verdict. That is the source of "a reader who may read sys_file never reaches the delegate on this path".
  • What is put to it, and how often. readParentDerivedFiles reads sys_file under { ...caller, isSystem: true }, where id $in is the ids the result holds, ref_object is this object and ref_id $in is the result's record ids. It then calls the registered authorizer once with the distinct owner ids. The authorizer is readableFieldOwners (packages/services/service-storage/src/storage-service-plugin.ts). When the owner object declares fileAccessDelegate, it calls delegate.authorizeFileRead(ownerId, authz) once for each owner id. That gives "once per owning record on each such read".
  • What a refused reader receives. An allowed row goes through servableFileRows and toFileValue, the same renderer the caller's own read uses, so the reader gets { id, name, size, mimeType, url }, or the bare id for an unservable row, exactly as a sys_file reader would. Every other id gets { id, metadataRefused: true }: one not owned by the record it sits on, one whose owner the verdict refused, and every id when the verdict is unwired or fails.
  • Download path. In buildFileReadAuthorizer, the uploader is allowed before the field-owned arm. The field-owned arm then calls the same readableFieldOwners with the one ref_id. That gives "unless the caller uploaded the file".
  • Fails closed. In readableFieldOwners, a getService throw, a missing delegate, a delegate with no authorizeFileRead, a falsy answer and a throw all leave that owner out of the readable set. The door then answers deny, and the record read keeps the marker.

Regenerated artifacts (generator output only, no hand edits)

  • pnpm --filter @objectstack/spec build (runs gen:schema), then pnpm --filter @objectstack/spec check:generated named exactly one stale artifact, content/docs/references/**. pnpm --filter @objectstack/spec gen:docs moved four pages: api/metadata.mdx, data/object.mdx, system/migration.mdx (two rows) and data/field-value.mdx.
  • node scripts/check-i18n-bundles.mjs --write --filter=platform-objects rewrote en.metadata-forms.generated.ts, one line.

A producer outside packages/spec: the three translated help texts

Studio renders the fileAccessDelegate help text in the author's locale. The zh-CN, ja-JP and es-ES values are hand-written translations, and --write keeps them in merge mode. Without an edit, those three locales would still say "downloads" only. No gate catches this: none of the three leaves has a source-hash record, so it counts as legacy-trusted. So the three values are updated by hand to say the same as en, and the changeset also carries @objectstack/platform-objects: patch, because the bundles ship in its dist. check:i18n and check:i18n-stale-fill are green after the edit.

Verification, at HEAD c5513532f2 (round 1) and ed75b6fea1 (patch round 1)

  • Patch round 1, at ed75b6fea1 (adds storage-service.ts and one changeset bullet; TSDoc only): pnpm --filter @objectstack/spec run typecheck exit 0; the spec local test project Test Files 642 passed (642), Tests 19164 passed | 1 todo (19165); dispatch-gates --ran "106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN"; check:generated "All 15 generated artifacts are up to date". The bullets below are round 1's, at c5513532f2.

  • pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2 (under os-verify-lock): Test Files 642 passed (642), Tests 19164 passed | 1 todo (19165).

  • pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2: Test Files 69 passed (69), Tests 1082 passed (1082).

  • pnpm --filter @objectstack/spec run typecheck exit 0. pnpm --filter @objectstack/platform-objects run typecheck exit 0.

  • pnpm --filter @objectstack/spec check:generated: "All 15 generated artifacts are up to date". pnpm check:i18n: "OK (9 package(s) — all bundles in sync, no undeclared authoring keys)". pnpm check:nul-bytes: OK.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, derived from this diff, gives 105 commands. All 105 ran, and the exit codes were recorded before reading. --ran reconciliation: "105 derived, 105 run, 0 NOT-MEASURED, 0 UNRUN". Two gates first answered exit 3, PREREQUISITE NOT MET, because packages were unbuilt in this worktree: check:skill-examples needed client-react's dist and check:dual-build-cjs-loads needed nine dists. After building those packages, both re-ran with exit 0.

  • Lint, narrowed and proven: ESLint ran on the 7 changed TypeScript files with --no-inline-config --format json: 7 files, 0 errors, 0 warnings. ① Population: ESLint's own isPathIgnored reads false for all 7. ② Count: from the JSON output. ③ Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot change a verdict on an untouched file. The repo-wide pnpm lint is CI's.

  • Not applicable: reverse verification and ablation. No type, schema or behaviour changed, so there is no assertion to flip.

Acceptance notes


Generated by Claude Code

…ecord-read verdict beside the download

fileAccessDelegate's describe, its TSDoc and the object form's helpText
now say the delegate also decides whether a reader who may not read
sys_file sees a field-owned file's name, size and type in a record read
(asked once per owning record per read). FileRefusedValueSchema's TSDoc
and its metadataRefused describe name the case where the record owns the
file and the download verdict allows it.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
… and metadataRefused describes

Output of `pnpm --filter @objectstack/spec gen:docs`; no hand edit.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
…cord-read verdict in all four locales

`en` is the output of `node scripts/check-i18n-bundles.mjs --write
--filter=platform-objects`. The zh-CN, ja-JP and es-ES values are
hand-written translations that merge mode keeps, so they are updated by
hand to say the same thing. Changeset: @objectstack/spec and
@objectstack/platform-objects patch, Clause-② no.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 6 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via /files/:fileId (route, a path literal in a comment on a changed line))
  • content/docs/automation/approvals.mdx (via fileAccessDelegate (symbol, a field of const object ObjectSchemaBase, an authorable key of data/Object), fileAccessDelegate (literal, a string literal in sections))
  • content/docs/concepts/metadata-driven.mdx (via ObjectSchemaBase (symbol, a top-level const object))
  • content/docs/protocol/objectql/types.mdx (via FileRefusedValueSchema (symbol, a top-level const), fileAccessDelegate (symbol, a field of const object ObjectSchemaBase, an authorable key of data/Object), fileAccessDelegate (literal, a string literal in sections), /files/:fileId (route, a path literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via /files/:fileId (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c63028e5bfb63aba438ed8de9febad86f448de91 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1bfe930fb43d06f8f5523bb869e295d4892e75fd — the merge of head ed75b6fea1452d725235ccf2af1f9b3dc6dc6ff1 into base c63028e5bfb63aba438ed8de9febad86f448de91, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1bfe930fb43d06f8f5523bb869e295d4892e75fd && git checkout 1bfe930fb43d06f8f5523bb869e295d4892e75fd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c63028e5bfb63aba438ed8de9febad86f448de91 ed75b6fea1452d725235ccf2af1f9b3dc6dc6ff1 && git checkout -B drift-repro c63028e5bfb63aba438ed8de9febad86f448de91 && git merge --no-ff ed75b6fea1452d725235ccf2af1f9b3dc6dc6ff1

node scripts/docs-audit/affected-docs.mjs --json c63028e5bfb63aba438ed8de9febad86f448de91

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c63028e5bfb63aba438ed8de9febad86f448de91 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…on beside the download

The interface docblock and authorizeFileRead's doc now say one verdict
answers both questions about a field-owned file: the download, and
whether a reader refused sys_file read sees the file's name, size and
type in a record read, asked once per owning record per such read. The
data-leak warning covers the metadata too; the fail-closed sentence is
kept. The changeset gains one bullet for it.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ed75b6fea1452d725235ccf2af1f9b3dc6dc6ff1
Local-runs: none

Inputs: card #22698 (body and every comment), PR #22713 (body, file list, net diff against main at this head), the head's check-runs. Each new sentence was read against the code it describes on origin/main at f66fdc7 via git show (packages/objectql/src/engine.ts resolveFileReferences / readParentDerivedFiles; packages/services/service-storage/src/storage-service-plugin.ts buildFileReadAuthorizer / readableFieldOwners; packages/services/service-storage/src/storage-routes.ts for owner_id). Nothing built, run or re-run.

① Derived judgments

Accept sets: none move. Each surface the diff touches, judged:

  1. ObjectSchemaBase.fileAccessDelegate (object.zod.ts): still z.string().optional(); only the .describe() string and the TSDoc change. Accept set unchanged. RIGHT.
  2. FileRefusedValueSchema (field-value.zod.ts): still a strict object of id and metadataRefused: z.literal(true); only the TSDoc and the metadataRefused .describe() change. Accept set unchanged. RIGHT.
  3. IFileAccessDelegate (contracts/storage-service.ts): authorizeFileRead(recordId, context) keeps its parameter and return types; docblock and member doc only. The published .d.ts carries new comments and no new or removed type. RIGHT.
  4. objectForm fileAccessDelegate row (object.form.ts): helpText string only; type: 'text' unchanged. RIGHT.
  5. The four locale bundles (en, zh-CN, ja-JP, es-ES metadata-forms.generated.ts): one string value each, keys unchanged. en is the generator's own rewrite; the other three are hand translations and each carries the same two additions as en (the metadata verdict for a reader without sys_file read; asked once per owning record on each such read) while keeping the locale's existing rendering of the rest. FAITHFUL. These four are the only metadata-forms bundles on origin/main, so no locale is left saying downloads only. RIGHT.
  6. The four reference pages: generator output. git grep on origin/main for the old .describe() sentence lists exactly api/metadata.mdx, data/object.mdx, system/migration.mdx (two rows), the en bundle, object.form.ts and object.zod.ts; for the old metadataRefused sentence exactly data/field-value.mdx and field-value.zod.ts; for the old IFileAccessDelegate question exactly storage-service.ts. Every tracked carrier of the old wording is in the PR's 13 files, and no tracked JSON artifact carries a description. The one other hit, .changeset/22593-file-field-hydration-refused.md, is a historical changeset and is rightly untouched. COMPLETE, RIGHT.
  7. Public-surface widening or narrowing: none. The new sentence "widens who can reach the bytes and the file's metadata" describes behaviour already on main (readableFieldOwners), not something this diff changes. RIGHT.

Truth of the new sentences, against the code:

  • "asked once per owning record on each such read": readParentDerivedFiles de-duplicates ref_id into ownerIds and calls the registered authorizer once; readableFieldOwners then calls delegate.authorizeFileRead(ownerId, authz) once per owner id. TRUE.
  • "a reader who may not read sys_file" / "when a reader's own sys_file read is refused": the parent-derived read runs only in the isReadRefusal(error) arm of resolveFileReferences, which matches the declared PERMISSION_DENIED code alone. TRUE. "A reader who may read sys_file never reaches the delegate on this path": the success arm goes to servableFileRows and toFileValue with no authorizer call. TRUE.
  • "reads its row under the system context": context: { ...caller, isSystem: true }, where on id $in, ref_object this object, ref_id $in the result's record ids, columns limited to PARENT_DERIVED_FILE_COLUMNS. TRUE.
  • "the owner object's fileAccessDelegate where it declares one, otherwise the caller's read of that record": the two arms of readableFieldOwners. TRUE.
  • "served as a sys_file reader sees it (name, size, mimeType, url)": toFileValue is the one renderer for both arms; an allowed row with no servable status reads as the bare id, which is what a sys_file reader gets for it too, so "as a sys_file reader sees it" covers that case. TRUE.
  • The marker's three meanings for a refused reader (the record does not own the file: copied in, attachment-only or unclaimed, no row; the verdict refused the owning record; the verdict could not be asked): valueFor refuses when hit.ownerId is not the record's id; an unwired authorizer, a result with no record ids, or a throw returns an empty map and every id keeps the marker. Matches the engine's own enumeration. TRUE.
  • "unless the caller uploaded the file": the door allows file.owner_id === authz.userId before the field-owned arm, and owner_id is stamped from the resolved upload session (storage-routes.ts, owner_id: session?.userId). TRUE.
  • "Fails closed on both paths" with "missing, does not implement the method, or throws": a getService throw, a missing delegate, a delegate without the method, a falsy answer and a per-owner throw all leave the owner out of the readable set; the door then answers deny and the engine keeps the marker. TRUE.
  • "with the caller's execution context": the door passes the resolveAuthzContext result (the full caller context); the engine passes withoutOperationPrivateKeys(execCtx). TRUE.
  • metadataRefused .describe(): "no read on sys_file, and the record being read does not own the file or its download verdict did not allow it" names the same two conditions. TRUE.

One imprecision, noted and not a FAIL item: readableFieldOwners returns the empty set before asking the delegate when the context carries no userId (the door's unauthenticated arm; an anonymous hydration caller). "Asked once per owning record on every such read" therefore over-states by that one edge, in the safe direction: an implementer expects a call that does not come, and the reader is refused either way. The docblock already on main above readableFieldOwners makes the same simplification. Nothing in the diff understates what the key decides, which is the trap the card names.

② Semver level

  • .changeset/22698-file-delegate-metadata-text.md: @objectstack/spec: patch, @objectstack/platform-objects: patch, Clause-②: no, no arm.
  • The diff publishes: the .describe() strings ship in spec's generated JSON Schema and dist, the TSDoc in its .d.ts, the bundle values in platform-objects' dist. So skip-changeset would be wrong and a changeset is owed. No accept set, export, key or type moves, so patch is both the floor and the ceiling. Clause-②: no with no arm is the well-formed spelling. RIGHT.
  • content/docs/** belongs to no released package and rides the spec entry. RIGHT.
  • The PR body's Clause-②: no line matches the changeset's. The Check Changeset check-run on this head is success (both runs). RIGHT.
  • The changeset prose: each bullet matches the diff (judged in ①). "A reader who may read sys_file never reaches it on a record read" and "It fails closed on both paths" hold. The "For authors" paragraph's "on every read" is shorthand for every read by a refused reader, which the same sentence names. Acceptable.

Check-runs on this head: every run is success or skipped (the skips are the second-run duplicates of Auto Label and Check PR Size, Console Pin Gate, and the opt-in Packed-tarball smoke); none failed, none in progress. Lint & Repo Gates carries the check:generated and check:i18n-stale-fill families; Spec property liveness, Build Docs, Check Documentation Links and the four Type Check lanes are success.

③ Boundary flags

open_questions is empty in both os-dev-reports (round 1 and patch round 1); nothing to answer there.

Round-1 deviations:

  • (a) fileAccessDelegate's TSDoc and metadataRefused's .describe(), beyond the claim's spot list but inside the three dispatched files. RIGHT to include: a TSDoc still reading "denies the download" beside a .describe() naming both paths would have been one key contradicting itself, and the metadataRefused description ships in references/data/field-value.mdx beside the marker's TSDoc the card names. Answered, no action.
  • (b) Hand-translated zh-CN / ja-JP / es-ES helpText in packages/platform-objects. RIGHT and necessary: merge mode keeps hand-written leaves, no source-hash record exists for them, so no gate would have flagged three Studio locales still saying downloads only, which is the card's own trap. The translations are faithful (①.5). The @objectstack/platform-objects: patch bump that follows is right because the bundles ship in its dist. The lane crossing is the dispatching seat's to own and the seat accepted it on the card. Answered, no escalation.
  • (c) NODE_OPTIONS heap for the spec typecheck, (e) two extra locked builds, and the patch round's queue-timeout re-runs and worktree recreation from c551353: local-run mechanics with no bearing on the contract. The head's check-runs are the verdicts and are all success. Answered, no action.
  • (d) origin/main moved during the run and was not merged in. The PR reads mergeable: true, mergeable_state: clean, and the four functions the text describes are present unchanged on origin/main at f66fdc7, where this review read them. Answered, no action.

Out-of-scope findings:

    1. IFileAccessDelegate docblock: resolved in this PR by patch round 1, judged in ① above. Closed.
    1. packages/spec/liveness/object.json fileAccessDelegate row: read on origin/main, status: live is right and the cited evidence (buildFileReadAuthorizer) is still the door that holds the delegate call's one definition through readableFieldOwners, so the row proves what it claims; only its note is now incomplete, in an internal ledger no author reads. The Spec property liveness check-run on this head is success. The seat's ruling to leave it in the PR's Acceptance notes stands; a one-line note refresh can ride the next edit of that row. Answered, no card.

Card acceptance, read against the diff: the three named texts and the form helpText name the metadata verdict beside the download; the marker's TSDoc names the owned-file case; the generated pages and the en bundle are regenerated and in sync; no behaviour change. Met.

Implemented-by: claude/issue-22698-file-delegate-metadata-text
Reviewed-by: session_01S3aAf11JjbW1mSGL1EhfFj

VERDICT: PASS

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

Labels

documentation Improvements or additions to documentation protocol:data size/m tooling

Projects

None yet

2 participants