Build

Artifacts

The claim-check store and the data legs — tools check data in and the model routes ~26-char tickets. ctx.artifacts, wants (refs as tool arguments), the present tool, the placement threshold, typed lifecycle events.

A production agent once hauled 879,073 tokens through its context window because two tools needed the same data and the only road between them ran through the model. The model read six megabytes it never needed, paraphrased it (imperfectly) back out, and the bill arrived anyway. Enterprise messaging solved this decades ago and named the law the claim check: the expensive channel carries a ticket plus enough description to decide — the payload never rides the channel. Your context window is the most expensive channel you own. Artifacts are that law, built in.

The idea in one run

import { Agent, defineTool, inMemoryArtifacts } from 'agentfootprint';

const getSales = defineTool({
  name: 'get_sales',
  description: 'Fetch Q3 sales and check them into the artifact store.',
  execute: async (_args, ctx) => {
    // Imagine 48,000 rows — 6 MB — coming back from your warehouse:
    const rows = Array.from({ length: 48_000 }, (_, i) => ({ order: i, amount: (i % 97) + 1 }));
    const meta = await ctx.artifacts.put({
      kind: 'dataset/rows',
      mediaType: 'application/json',
      data: rows,
      label: 'Q3 sales by region',
    });
    // The model reads THIS — one line, ~30 tokens:
    return `stored ${meta.ref} [${meta.kind} · ${meta.bytes} bytes]`;
  },
});

const summarize = defineTool({
  name: 'summarize',
  description: 'Total a stored dataset. Pass the art_… ref from get_sales.',
  inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'] },
  execute: async ({ ref }: { ref: string }, ctx) => {
    const head = await ctx.artifacts.head(ref); // decide from meta — pay nothing
    if (head === null) return `nothing under ${ref} — expired or never stored`;
    const got = await ctx.artifacts.get(ref); // now pay for the bytes
    const rows = got?.data as ReadonlyArray<{ amount: number }>;
    return `total: ${rows.reduce((sum, row) => sum + row.amount, 0)}`;
  },
});

const agent = Agent.create({ provider, model, artifacts: inMemoryArtifacts() })
  .tool(getSales)
  .tool(summarize)
  .build();

The model routes the ticket (art_h7Kq…) between tools. The window spent on six megabytes of freight: two metadata lines. That is the whole feature; everything below is the law behind it.

Runnable version: examples/features/56-artifacts.ts — mint in one tool, redeem in another, a derived artifact, and expiry stating itself.

The data legs (9.22.0) — refs the model routes, not just holds

The store alone still leaves the model doing the redeeming by hand (ctx.artifacts.get inside the tool). Three legs make the ref a first-class citizen of dispatch itself.

wants — refs as tool arguments, resolved before execute

A tool declares which arguments are claim tickets, and what kind they must redeem to:

import { Agent, defineTool, inMemoryArtifacts } from 'agentfootprint';

// 1. get_data returns 48,000 rows. It doesn't even call the store —
//    the placement threshold (below) checks the result in for it, as
//    kind 'tool-result/get_data', and the model reads the ticket.
const getData = defineTool({
  name: 'get_data',
  description: 'Fetch the Q3 sales rows (large).',
  execute: () => Array.from({ length: 48_000 }, (_, i) => ({ order: i, amount: (i % 97) + 1 })),
});

// 2. transform_report DECLARES its ref argument. The model passes the
//    art_… STRING; the framework resolves it at dispatch — the handler
//    receives the DATA, and the claim ticket rides ctx.wanted.
const transformReport = defineTool<{ dataset: string }, string>({
  name: 'transform_report',
  description: 'Aggregate a stored dataset. Pass the art_… ref from get_data.',
  inputSchema: {
    type: 'object',
    properties: { dataset: { type: 'string' } },
    required: ['dataset'],
  },
  wants: { dataset: 'tool-result/get_data' },
  execute: async (args, ctx) => {
    const rows = JSON.parse(args.dataset) as ReadonlyArray<{ amount: number }>; // the data, already redeemed
    const meta = ctx.wanted?.dataset; // the ticket (ArtifactMeta)
    return `total: ${rows.reduce((s, r) => s + r.amount, 0)} (from ${meta?.ref})`;
  },
});

const agent = Agent.create({
  provider,
  model,
  artifacts: { store: inMemoryArtifacts(), placement: { maxInlineChars: 2_000 } },
})
  .tool(getData)
  .tool(transformReport)
  .build();

The declaration's shape is the exported ToolWants type — Readonly<Record<argName, kind>>. The law, verb for verb the needs (credentials) precedent:

  • Resolution happens at dispatch, before execute, before credentials — a call whose data cannot be delivered never acquires a secret and never runs.
  • A stale, unknown, or wrong-kind ref never reaches the tool. The model reads a teaching refusal that lists the live refs of the wanted kind in scope — correction by naming what can resolve, never just what can't. On the record: agentfootprint.artifacts.refused with op: 'dispatch' (reason missing-or-expired, kind-mismatch, invalid-input, or no-store); successful resolution rides the existing artifacts.resolved (via: 'get').
  • Kind check is exact. 'dataset/rows' matches only 'dataset/rows' — no wildcards, no hierarchy.
  • Declared honestly or refused at defineTool: a wants argument must exist in inputSchema.properties and be type: 'string' (the model speaks the ref, never the bytes). An agent with a statically registered wants tool and no store refuses at build — configuration that lies otherwise. mcpServe refuses wants tools by name (no store at that door).
  • Zero-cost: a tool without wants takes the exact path it always took, and ctx.wanted is absent (absent ≠ empty).

The placement threshold — oversized results become tickets automatically

The object form of the option (the exported AgentArtifactsOptions{ store, placement? }) carries the operator's dial, an ArtifactPlacement: artifacts: { store, placement: { maxInlineChars: N } }. Any tool result whose finalized text exceeds N is checked into the store as kind tool-result/<toolName> — the exported placedResultKind(toolName) composes that vocabulary, so a wants declaration can name it without spelling the prefix — with label <toolName> result (the payload is the exact text the model would have read), and every channel — history, stream.tool_end, recorders — carries the one-shape PlacedToolResult substitute instead:

{ "placed": true, "ref": "art_h7Kq…", "kind": "tool-result/get_data",
  "mediaType": "application/json", "bytes": 5000000,
  "reason": "get_data returned 5000000 chars, over the 2000-char placement threshold, so the full result was stored as artifact 'art_h7Kq…' … Route the ref: pass it to a tool whose argument wants 'tool-result/get_data', or call present({ ref, as: … })." }

Branch on .placed (isPlacedToolResult); the decision is never silent — the mint lands as artifacts.minted with origin: { runId, toolCallId }.

Precedence with the other two ceilings, stated and tested:

  1. the tool's own resultCeiling (the author's refusal) is judged first, at the execute boundary — a refused result is short, so placement never sees the payload;
  2. placement (the operator's ref-ing) is judged next, on what the after-tool governance chain let through;
  3. the agent-level maxToolResultChars truncation net runs last — with placement on, it measures the ticket and should rarely fire.

A placed result is a ticket, not a refusal: declared tool effects are still judged and procedure steps advance normally — the result arrived; it just travels by reference. Errored/denied/refused calls are never placed (tool-result/<name> must never claim an error is the tool's result). Placement cannot be spelled without a store — the option's own shape refuses it.

present — hand a ref to the screen, with a durable snapshot

When a store is attached, the framework auto-attaches one more tool (the read_skill seam — the name is the exported PRESENT_TOOL_NAME constant, and it is reserved exactly then): present({ ref, as, label? }). It heads the ref under the run's scope — never get; the screen pays for bytes later, under its own identity — and the result the model (and the transcript) keeps is a PresentedResult carrying the description snapshot (a PresentSnapshot):

{ "presented": true, "ref": "art_9mTw…", "as": "bar-chart",
  "snapshot": { "kind": "chart/spec", "mediaType": "application/json",
                "bytes": 41210, "label": "Q3 sales by region" } }
  • as is consumer vocabulary ('bar-chart', 'table', 'image'…) — stored as data, deliberately unvalidated: the component registry that would check it is the frontend phase.
  • The snapshot lives inside the tool result — the one thing provider message history keeps typed and durable — so a conversation reloaded after the artifact expired can still render an honest placeholder from history alone: "Chart — 'Q3 sales by region' (bar-chart, 41 KB) — expired; re-run to regenerate." Never a blank pane.
  • A miss is a teaching refusal listing the live refs in scope, error: true on the call, and artifacts.refused (op: 'dispatch') on the record. A hit emits the typed agentfootprint.artifacts.presented { ref, as, snapshot }.
  • The model never serializes what the screen will show.

The code leg — CodeResult.artifacts gets the store behind it

With a store attached, files a code run hands back in-band (CodeResult.artifacts[].data — an additive field an adapter fills when it can return the bytes) are minted under the run's scope — kind file/<ext> from the producer's own filename, mediaType from the adapter's statement, a well-known-extension table, or the payload's shape — and the rendered result names the ref ([artifact: report.csv, 29 bytes, stored as art_… (file/csv) — route this ref…]). Entries without data stay described-only, exactly as before. Since 9.26.0 the leg runs both ways: codeRunnerTool({ wants }) stages the resolved payload INTO the session as a file through CodeSession.stageInputs, so a dataset reaches the interpreter without one byte of it entering the prompt. See Staging refs into a code session.

Runnable version of the whole flow: examples/features/57-artifact-data-flow.ts — placement mints the 48k-row result, the ref rides a wants argument, and present finishes with the snapshot, end-to-end on the mock provider.

The HITL leg (9.24.0) — an ask's big props ride the store, not the checkpoint

A human ask can carry a typed component: { componentId, props?, propsRef? } — which registered screen component collects the answer. propsRef is this store doing for questions what wants does for tool arguments: a 200-option picker's options are minted via ctx.artifacts.put(...) BEFORE the ask is raised, the ask carries the ~26-char ticket, and the screen redeems it through the artifact wire under the session's own scope — the checkpoint and every stored session envelope stay lean. The ref is validated to resolve at raise time, so a dangling ticket refuses at its source instead of reaching the person answering. See Pause / Resume — typed asks and Check-in; runnable: examples/features/58-typed-hitl-component.ts.

Recordings as artifacts (9.26.0) — the Lens over the wire

A finished run already has a shape every viewer reads: { snapshot, events, structure }, produced by recordRun. What nobody shipped was the boring half in between — where a completed run's recording goes so a screen can ask for it later. Every deployment that wanted the Lens over the wire wrote the same twenty lines, and each one wrote a slightly different, slightly wrong version of retention and scoping.

All four of those problems already had an answer here. A recording is simply an artifact:

const agent = Agent.create({
  provider, model,
  artifacts: { store: sqliteArtifacts({ file: './artifacts.db' }), recordings: true },
}).build();

await agent.run({ message: 'Q3 by region?' });
// → one artifact of kind 'recording/run', in the run's own scope,
//   with origin.runId joining it back to the trace.

The frontend redeems it with the operation it already speaks: { op: 'artifact-get', ref } returns the recording, and observeRecording(JSON.parse(text)) draws it. Zero new wire operations were needed. Retention rides the store, so recordings age out under the same ttl and byte budget everything else does, and a sweep reports itself on agentfootprint.artifacts.expired exactly as any other eviction does.

recordings: true takes the default naming; AgentRecordingsOptions{ label } — names them yourself, and the label is used verbatim. A static label repeats on every run on purpose: what distinguishes two recordings is the ref and origin.runId, and a library that decorated your label to make it unique would be overruling the name you chose.

What it costs, stated rather than discovered

Recording a run means an event tail and a boundary recorder for its duration, and the mint is one store write on the way out of run(). The answer is fully composed before the write begins and the write can never change it — but run() does return after it rather than before. That is deliberate: a fire-and-forget write is a recording lost whenever the container exits with the reply, which is exactly the deployment that wants this most.

A mint that fails degrades to the old behaviour: the answer is returned unchanged and the reason lands on the record as agentfootprint.artifacts.refused. A run never fails because its recording could not be filed. Nothing is minted for a run that paused (the turn is not over — the resume mints its own) or threw.

The payload is the recording's JSON text — the same bytes JSON.stringify(recorder.toRecording()) produces. recordRun states that snapshot and structure are the runner's own objects held by reference, and an in-process store handed those would keep a live view into a finished run's state; serializing detaches it, once. A recording JSON cannot carry (a cyclic snapshot) is refused at the mint by UnserializableRecordingError rather than at whatever tried to read it.

Unset — the default — no recorder is attached, no events are captured, nothing is minted, and the run is byte-identical to every earlier release.

For custom harnesses the pure half is public: recordingPutInput(recording, facts) turns a recording into the PutArtifactInput that stores it, taking RecordingMintFacts ({ runId?, label? }); RECORDING_ARTIFACT_KIND ('recording/run') and RECORDING_MEDIA_TYPE ('application/json') are the constants a consumer branches on.

ctx.artifacts — shaped exactly like ctx.credentials

Every tool's execute context carries ctx.artifacts (a ToolArtifacts) and ctx.hasArtifacts:

  • Always present, fail-closed. With no store attached, every method throws a teaching refusal naming the fix (Agent.create({ ..., artifacts })) — a missing store can never read as an empty one. Branch on ctx.hasArtifacts for an intentional degraded mode.
  • Scope is composed by the framework, never by the tool. The capability closes over the run's own tenant/principal/conversation tuple (the same ArtifactScope — an alias of the memory identity tuple — that memory scopes on). A tool cannot name, widen, or replace the scope it resolves under.
  • origin is stamped from the run's facts. Every mint carries { runId, toolCallId } (ArtifactOrigin) — the join to the causal record. A caller-supplied origin is discarded.
  • Zero-cost when unused. No store attached ⇒ byte-identical behavior and events. Nothing fires unless a tool actually calls the capability.

The port — five verbs, no more

ArtifactStore is the vendor-neutral port every adapter implements. Scope is always the first argument (the MemoryStore constitution):

verbreturnslaw
put(scope, input)ArtifactPutResult — the minted ArtifactMeta + every SweptArtifact retention evictedvalidates input (PutArtifactInput), proves parentRefs, measures bytes, stamps expiresAt
head(scope, ref)ArtifactMeta | nullthe ticket without the bytes — the render-by-ref decision
get(scope, ref)ArtifactRecord | nullmeta + payload; verifies digest when present
delete(scope, ref)voidremoving an absence is agreement, not an error
list(scope, options)ArtifactListResultcursored pages (ArtifactListOptions), newest first, meta only

get and head return null for missing or expired — the same deliberate ambiguity as memory: "no data" is the only actionable fact, and distinguishing the two would let a caller probe another scope's contents. The record still tells the truth (see events below).

The refusal, written down so it can be cited: the port will never grow query() or transform(). The moment it does, this is a database. Compute over artifacts belongs to generated code (a later phase); five verbs is the ceiling.

…and two OPTIONAL streaming members (9.25.0)

A claim check exists so a 6 MB result never rides the conversation. It does not follow that it should ride the process: putStream and getStream let a payload move without either side holding it whole.

They are optional members, not a sixth and seventh verb — a store that cannot honor the promise leaves them absent rather than faking it, and you feature-detect with narrowing guards:

import { canPutArtifactStream, canGetArtifactStream, canStreamArtifacts } from 'agentfootprint';

if (canPutArtifactStream(store)) {
  await store.putStream(scope, { kind: 'report/csv', mediaType: 'text/csv', bytes: 4_096 }, body);
}

Calling an undetected member is a type error, not a runtime surprise. Each guard narrows: canPutArtifactStream proves the store really has putStream, canGetArtifactStream proves it has getStream, and canStreamArtifacts narrows to StreamingArtifactStore when you need the round trip. After a guard the call compiles with no ! and no cast, so the honest branch is also the comfortable one.

storestreams?why
fileArtifactsyesbytes go to a sibling <ref>.bin as they arrive
s3Artifacts / gcsArtifactsyesthe SDKs' own streaming upload/download
sqliteArtifactsnonode:sqlite reads and writes a BLOB whole — a "stream" here would buffer everything and call it a stream
inMemoryArtifactsnoit holds payloads whole under a byte budget; streaming into it would defeat both

A streamed put (ArtifactStreamPutInput) costs you exactly two things, both stated rather than silently traded:

  • bytes is required — it is stated, not measured. Retention has to plan an eviction and an object store has to declare a content length before the first chunk arrives. A payload that does not match what it declared is refused, never stored under a meta that misdescribes it.
  • there is no digest. A digest covers the whole canonical payload, computed with the one primitive every adapter shares; a store that never holds the payload cannot produce one, and an incremental hash computed a second way would be a different promise wearing the same field name. get remains the verifying read — getStream (an ArtifactStreamRecord) does not re-verify, because verification needs the whole payload, which is the thing streaming exists to avoid.

ctx.artifacts does not grow streaming this phase — a decision, not an omission. A tool receives its artifacts pre-scoped and answers a model; the model cannot hold a stream. A tool that genuinely moves gigabytes holds the store (it already constructed it) and calls it directly, while ctx.artifacts stays the small, scope-bound surface a model-driven tool can be trusted with.

The ref is minted, never derived

An ArtifactRef is art_ + 22 crypto-random chars (~26 total — ARTIFACT_REF_PREFIX, mintArtifactRef, isArtifactRef). It is never content-addressed: the same bytes stored by two tenants must be two objects, and a digest can't name two generations of "the current dataset". The content digest (sha-256, computed at put when you ask) is metadata — it buys integrity checking on get (a mismatch throws ArtifactIntegrityError rather than delivering corrupt bytes as whole) — but it is never the key.

Security, day one

A ref alone opens nothing. Resolution takes the ref and the caller's scope. A ref pasted into a log, a model response, or a bug report is safe by construction — under any other session's scope it resolves to null. An anonymous run scopes to its own runId (the descriptor table dies with the process); a session-bound run to its sessionId (turn two of the session redeems turn one's tickets); an identity-carrying run to the caller's tenant/principal.

Bytes never appear in events. Every agentfootprint.artifacts.* payload carries metadata only.

Five adapters

import { inMemoryArtifacts, fileArtifacts, sqliteArtifacts } from 'agentfootprint';
import { s3Artifacts, gcsArtifacts } from 'agentfootprint';

inMemoryArtifacts()                             // dev/tests — bounded, drop-counting
fileArtifacts({ directory: './artifacts' })     // one legible JSON file per artifact
sqliteArtifacts({ file: './data/artifacts.db' })// one SQLite file — pairs with sqliteSessions
s3Artifacts({ bucket: 'my-agent-artifacts' })   // a fleet, in an S3 bucket
gcsArtifacts({ bucket: 'my-agent-artifacts' })  // a fleet, in a Cloud Storage bucket

One ladder, one contract: a tool that stored a dataset against the in-memory store stores it in a bucket by swapping one constructor. The five run the same contract suite — a cloud column that drifts from the port fails the shipped adapters' own tests.

  • inMemoryArtifacts (an InMemoryArtifacts) is always bounded: 32 MiB and 256 artifacts per scope by default (DEFAULT_IN_MEMORY_ARTIFACT_RETENTION, dials via InMemoryArtifactsOptions), least-recently-used evicted first (reads refresh recency), and it counts its drops (store.dropped) — "we kept the last 200 of 340" is an answer; a silently missing ref is not.
  • fileArtifacts (FileArtifactsOptions) partitions scopes into percent-encoded directories, so .. and / in a tenant name arrive as data and land as literal directory names — never as navigation. A file that exists but cannot be read is refused by name (UnreadableArtifactFileError), never reported as absent.
  • s3Artifacts (S3ArtifactsOptions) and gcsArtifacts (GcsArtifactsOptions) put artifacts in somebody else's bucket — the third rung of the same ladder. Both partition scopes into percent-encoded key segments using the same encoding law as the directory adapter (a tenant of literally .. is a name), carry the ticket as one object-metadata entry of ASCII JSON, store the payload as the canonical bytes of the object body (so a stored report is downloadable with the vendor's own console and is the report), and answer null only for a missing object — a denied, throttled, or misdirected call is an error, never "no data". A not-found is only "no data" when the call asked about one object: a 404 from a write or a listing means something the caller never asked about is wrong (most often that the bucket does not exist), so it raises with the SDK's own text withheld rather than being swallowed as an empty scope. Both ship as contract-shaped and tested; awaiting field use — no live call has been made from this repository. See AWS and Google Cloud for keys, costs, and how to align the operator's lifecycle rules.
  • sqliteArtifacts (a SqliteArtifacts, SqliteArtifactsOptions) follows the sqliteSessions laws line for line: lazy node:sqlite (no install; refused by name on Node without it), WAL with the journal mode read back, STRICT tables you can inspect from the sqlite3 command line, ':memory:' refused, and a file that is somebody else's schema — or a newer one — refused as UnreadableArtifactStoreError rather than half-read. Point it at a file beside your sqliteSessions store and artifacts live exactly as long as the conversations that can speak their refs.

Retention — expiry is stated, never sprung

ArtifactRetention is three per-scope dials on any adapter: ttlMs, maxBytesPerScope, maxCountPerScope (ArtifactSweepReason names which one fired). The law: expiry is stated at mint — a ttl stamps expiresAt on the meta, so a consumer can reason about lifetime instead of discovering it. The store may tighten a caller's own expiresAt, never extend it. Budget evictions are reported by the put that forced them and land on the record; a payload larger than the whole byte budget is refused (InvalidArtifactError) instead of emptying the scope to admit it.

Derivation is a fact, not an engine

parentRefs on a mint records what an artifact was computed from — validated at mint, so a parent that does not resolve in the same scope is refused (UnknownParentRefError): a foreign key that cannot dangle at birth. There is deliberately no lineage-graph engine — walking parents is a fold over head(), and the moment your question becomes "why does this chart disagree with that dataset," that is causation, which the trace already owns.

The lifecycle, on the record

Five typed events (domain wildcard: agent.on('agentfootprint.artifacts.*', …)):

eventpayloadfires when
agentfootprint.artifacts.mintedArtifactMintedPayload — ref, kind, bytes, origin, parentsa tool checked a payload in (or placement did it for one)
agentfootprint.artifacts.resolvedArtifactResolvedPayload — ref, via: 'head' | 'get'a ticket was redeemed — by a tool, or by dispatch resolving a wants argument
agentfootprint.artifacts.expiredArtifactExpiredPayload — ref, reason (ttl / max-bytes / max-count)retention swept one
agentfootprint.artifacts.refusedArtifactRefusedPayload — op, reason, ref?a verb refused — no store, missing-or-expired, unknown parent, digest mismatch, invalid input, kind mismatch; op: 'dispatch' marks the framework's own door (wants / present)
agentfootprint.artifacts.presentedArtifactPresentedPayload — ref, as, snapshotthe model handed a ref to the screen

This is what separates the store from plain "where are the bytes" systems: every mint, hop and refusal lands on the same causal record as the rest of the run, so "where did this number come from?" is a query, not an archaeology dig.

For custom wiring outside the Agent (your own harness, tests), the binding layer is public: bindArtifacts(store, scope, options) (a BindArtifactsOptions with origin + an ArtifactEventSink receiving each ArtifactEventFact — op vocabulary ArtifactOp, refusal vocabulary ArtifactRefusalReason, put shape ToolArtifactPutInput) and unconfiguredArtifacts(), the fail-closed teacher itself.

Decisions worth knowing

  • One store per agent, attached at construction (Agent.create({ ..., artifacts }) — bare store, or { store, placement }) — there is no second door, so "one per agent" is a fact of the type.
  • No snapshot coupling. getSnapshot() is unchanged; the store's own accounting is list plus the adapters' counters. Stated so the absence reads as a decision.
  • The hosting half of render-by-ref is live (9.23.0): a served agent answers artifact-head / artifact-get on its invoke path, so a screen redeems tickets under the requesting session's identity — see Redeeming claim tickets.
  • The reference architecture is complete as of 9.25.0: typed HITL payloads landed in 9.24.0, and the cloud adapters, the optional streaming leg and the skill artifact vocabularies landed here. Since 9.26.0 the data legs run both ways: staging refs INTO code sessions landed with CodeSession.stageInputs, and run recordings are artifacts the existing wire ops serve. The component registry that validates as (the lens leg) remains a later phase.

Skill vocabularies — which kinds a skill makes and needs

An artifact kind is a consumer vocabulary. As of 9.25.0 a skill can declare which kinds it produces and consumes, so the data legs of a run are checkable before the run:

defineSkill({
  id: 'charting',
  consumes: ['dataset/rows'],   // what must have arrived
  produces: ['chart/spec'],     // what it leaves behind
  steps: [{ tool: 'render', note: 'draw it', consumes: ['dataset/rows'] }],
});

These are declarations, not machinery — nothing at run time reads them, and a skill that declares none is byte-identical to one that never heard of them. What they buy is a build-time warning (artifact-kind-unsatisfied) when a consumer needs a kind nothing on the agent claims to make, and a fact a lens can draw. The full rule — and the honest statement of what a static check cannot see — is on the Skills page.

On this page