Skip to content

fix(runtime): every door that starts a flow by name refuses a record key the caller cannot read before the run exists - #29227

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-4gg947
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-4gg947

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread
Fixes #29050

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:所有按名字启动 flow 的入口(两种写法的 trigger 路由、声明式 type: 'flow' endpoint、flow 类 action 的 REST 与 MCP 入口)在启动前,都用调用者自己的权限读一次这次启动交给运行的记录;读不到就回 action 入口同一个 404 RECORD_NOT_FOUND,不产生运行记录。
  • 为什么改:三个入口对「这次启动指向的记录,调用者能不能读」问法不一致,action 入口问了,其余入口没问;以 runAs: 'system' 运行的 flow 会越过调用者自己的读权限。本 PR 让三个入口问同一个问题、给同一个答复。
  • 风险与回滚:调用者能读的记录、不指定记录的启动、系统身份(进程内调用方)都不变。新增三种拒绝:读不到或不存在的记录回 404;以系统身份运行的 flow 指定了记录但没指定对象回 400;记录键不是单个 id 回 400。用户身份 flow 在指定了对象时也会检查,与 action 入口一致。回滚即撤掉本 PR。
  • 先看:packages/runtime/src/action-execution.ts 的 refuseUnreadableFlowStartRecords 和 flowActionCallerRecordKeys;domains/automation.ts 的 respondToFlowTrigger 与 flowStartRecordKeys;endpoint-executor.ts 的 executeFlow。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/flow-start-record-admission.test.ts,应看到 32 条全过。
  • 需要你做的:无,席位落地。

What changes

Every door that starts a flow by name now asks a third admission question after flowStartRefusal and before execute(): the row this start addresses. One function, refuseUnreadableFlowStartRecords (packages/runtime/src/action-execution.ts, beside the action door's subject load that it reuses), is called by:

  • the trigger routes, POST /api/v1/automation/:name/trigger and the legacy POST /api/v1/automation/trigger/:name (respondToFlowTrigger, domains/automation.ts);
  • a declared endpoint of type: 'flow' (executeFlow, endpoint-executor.ts);
  • an action of type: 'flow' at the REST action route and the MCP run_action tool (dispatchFlowAction), for the row slots the caller's own params fill. The routed record keeps the subject load and refusal it already had, unchanged.

The rule it applies:

  • Which keys. Every record key in a row slot the door itself defines (recordId, the {objectName}Id alias, an action's declared recordIdParam), with the value the run will receive, a caller's own params value included.
  • The load. Each key is loaded in the caller's own scope through the door's own caller-scope read (callData('get', …), the read the action door's subject load makes), via loadActionSubjectRecord.
  • The refusal. A key that scope does not deliver is refused by refuseDeniedSubjectLoad: the action door's own 404 RECORD_NOT_FOUND (ADR-0112 envelope). An unreadable record and an id that names nothing get one answer. Nothing runs and no run is persisted.
  • The object. The object the start names: objectName (or object) in a trigger or endpoint body, the action's own object at the action door. A start that names a record key but no object cannot be judged: a flow declared runAs: 'system' refuses it 400 VALIDATION_FAILED naming objectName; a flow that runs as the user starts as before.
  • Non-id keys. A record key whose value is not one id (a string or a number) is refused 400 VALIDATION_FAILED naming the key, and is never loaded.
  • Unchanged. A start whose caller can read the record; a start that names no record; the system principal; an action whose recordIdField names a field other than id (its row slots carry that field's value, not an id).

Supporting changes:

  • flow-start-admission.ts: the module docblock states the third question, and flowRunsElevated is the one reading of runAs it needs. The module still imports nothing from action-execution.ts.
  • domains/automation.ts: recordIdAlias is now the one spelling of the {objectName}Id alias, read by buildAutomationContext to seed it and by the new flowStartRecordKeys to judge it. The trigger routes answer the refusal through deps.errorFromThrown, the exit the action door serves the same refusal through, so both doors answer one envelope.
  • content/docs/permissions/system-context.mdx: row 57 (the flow start admission bypass of the system principal) now names this check and its symbol, and the site register is regenerated with check-system-context-census.mjs --write. The new function reads isSystem once, so check:system-context-census required a row to own it.
  • Changeset: .changeset/29050-flow-start-record-admission.md, a patch for @objectstack/runtime, rule-only.

Why

#29050: the doors that start a flow by name did not all ask whether the caller can read the record the start hands the run, so the three doors disagreed. flow-start-admission.ts already says a door left out of a start rule is the defect. This PR states the rule once and has every door ask it.

Tests

  • New packages/runtime/src/flow-start-record-admission.test.ts (32 tests), over a row-scoped data double:
    • at both trigger spellings, a flow endpoint, the REST action route and the MCP action tool, a start door given a record key the caller cannot read is refused 404 RECORD_NOT_FOUND, and no run is created;
    • a record that does not exist gets the identical answer;
    • a start on a record the caller can read starts the run (the firing control at each door);
    • each row slot is judged with the value the run receives;
    • an elevated start that names a record but no object is refused 400 naming objectName, and nothing is loaded;
    • a record key that is not one id is refused 400 naming the key, and nothing is loaded;
    • unchanged cases: no record key, the system principal, a user-run flow with no object, an action whose row slots carry a non-id field;
    • the trigger route and the action door answer the same status, code and message for the same refusal.
  • Forced test edits (8 files). Each fixture started a flow on a record against a data double that did not serve that record to the caller. Each now serves it; no assertion changed:
    • flow-run-locale.test.ts, flow-run-posture.test.ts, flow-caller-param-keys.test.ts: the trigger-route cases' kernel also serves a data read for the launched record.
    • domains/automation-trigger-params-shape.test.ts: the dispatcher double also serves the launched record (the a record launch case).
    • domain-handler-registry.test.ts: the both-routes identity case serves its launched record.
    • endpoint-executor.test.ts: the flow delegation case's callData double returns the launched record.
    • dispatcher-plugin.endpoint-fallback.integration.test.ts: the fake engine serves the row the flow endpoint is launched on.
    • http-dispatcher.actions-type-dispatch.test.ts: in lets an explicit param win over every seeded key, the caller can also read the record its own param names.

Acceptance notes

  • Doors measured (the card's assumption A). Four entrances reach IAutomationService.execute by flow name from a caller: the two trigger spellings, a declared type: 'flow' endpoint, and dispatchFlowAction (shared by the REST action route and the MCP run_action tool). All four now ask the question. A parent flow's subflow node starts its child through the engine, not a door, as flow-start-admission.ts already notes.
  • The object (assumption B). The trigger and endpoint bodies carry objectName/object, and the action door has its action's object. A record key with no object is refused for an elevated flow and passes for a user flow, as described above. No packages/spec change.
  • User-run flows (assumption C). The check also runs for user flows when the start names an object, the same way the action door's routed-record load already runs for every flow. Measured cost: 12 existing tests in 8 files had to serve their launched record (the forced edits above).
  • Before any run. Every refusal is asserted with execute never called.

Verification

Head 2020b6e5. Code readings were taken on 5fda11a5 (the merge of origin/main at 4550091a); 2020b6e5 adds only the docs row above. Heavy runs went through os-verify-lock.sh.

Runtime package: typecheck, full test, lint
  • pnpm --workspace-concurrency=2 --filter '@objectstack/runtime^...' build after the merge: VERDICT command-exit 0.
  • pnpm --filter @objectstack/runtime typecheck (includes check:test-typecheck): VERDICT command-exit 0.
  • pnpm --filter @objectstack/runtime test: Test Files 515 passed (515), Tests 8350 passed, 19 skipped (8369). VERDICT command-exit 0.
  • Before the fixture edits, the same full run read Test Files 8 failed, 507 passed (515); Tests 12 failed, 8338 passed. All 12 were the record launches named under Forced test edits, failing with expected 404 to be 200, an execute mock called 0 times, or (the action door case) a run that never started.
  • vitest run over the 8 edited files plus the new file: Test Files 9 passed (9), Tests 250 passed (250).
  • eslint packages/runtime --no-inline-config (the root pnpm lint command, scoped): exit 0, no output.
Ablations: every new refusal goes red when the check is taken out

Each leg used scripts/ablation-replace.mjs on one anchor, ran vitest run src/flow-start-record-admission.test.ts through the lock, and restored. git diff HEAD was 0 bytes after every leg.

  • Trigger routes (the trigger door hands the check no keys): 17 failed, 15 passed. For example, expected 200 to be 404, expected 200 to be 400, and the parity case expected { status: 200, … } to deeply equal { status: 404, … }.
  • Flow endpoint (no keys): 1 failed (a record key the caller cannot read: 404 RECORD_NOT_FOUND, no run): expected 200 to be 404.
  • Action door (no caller-param keys): 3 failed (REST ×2, MCP): expected 200 to be 404, and promise resolved "{ ok: true, … }" instead of rejecting.
  • No object on an elevated flow (always start): 2 failed: expected 200 to be 400.
  • Non-id key (loaded instead of refused): 2 failed: expected 404 to be 400.
  • The {object}Id slot (dropped from the keys): 2 failed: expected 200 to be 404.
dispatch-gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 2020b6e5: 93 commands. Each ran with stdin closed after a full turbo run build --filter='./packages/**' (Tasks 78 successful, 78 total; VERDICT command-exit 0). All 93 exited 0, check:nul-bytes included.
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE: "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a DERIVED zero — all 93 recorded an exit code and none of them is 3)". Exit 0.
  • On the first pass (head 5fda11a5), check:system-context-census was red: the new function reads isSystem and no row on content/docs/permissions/system-context.mdx owned it. Row 57 now does (commit 2020b6e5). Two prerequisite exits on that pass were cleared and pass on the final run: the teardown-shape self-test's pinned fixture commit was fetched, and the CJS-load gate ran after the full build.
  • Not measured locally, as --commands lists them: the families whose argv takes a value only a CI run has (check-issue-citations.mjs --census, the shard attestation emitters, check-test-completeness.mjs), and the CI-scheduled jobs (Build Core, Build Docs, the type-check lanes, and the main-only dogfood and temporal batteries). Those are CI's readings.

Human Minimum

Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…run in the caller's scope before the run exists

WIP: the shared check (refuseUnreadableFlowStartRecords) beside the action
door's subject load, called by the trigger routes, a declared flow endpoint
and the action door's flow branch.

Claude-Session: https://claude.ai/code/session_01ADHkmT7iCiGQkgXGQAo4Fo
Co-authored-by: Claude <noreply@anthropic.com>
… a flow endpoint and the action door

Claude-Session: https://claude.ai/code/session_01ADHkmT7iCiGQkgXGQAo4Fo
Co-authored-by: Claude <noreply@anthropic.com>
…aunch on

Eight files started a flow on a record against a harness with no data read
for that record (or, at the action door, with a caller param naming a second
row the harness did not serve). Each now serves the row to the caller, so the
assertions they pin are unchanged and reached as before.

Claude-Session: https://claude.ai/code/session_01ADHkmT7iCiGQkgXGQAo4Fo
Co-authored-by: Claude <noreply@anthropic.com>
…d on the system-context page

The flow start check that loads a start's record keys in the caller's scope
skips the system principal, as the two admission rules already do. Row 57
now names that bypass and its symbol, and the site register is regenerated.

Claude-Session: https://claude.ai/code/session_01ADHkmT7iCiGQkgXGQAo4Fo
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 12 documentable anchor(s).

32 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: objectName (literal, 55 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 28 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 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from af4d16b511f6487fb132b6c2e274108b5b02233e — the merge of head 0258ffd81f0ee054709dc272919c75c2db3daf40 into base 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16, 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 af4d16b511f6487fb132b6c2e274108b5b02233e && git checkout af4d16b511f6487fb132b6c2e274108b5b02233e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16 0258ffd81f0ee054709dc272919c75c2db3daf40 && git checkout -B drift-repro 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16 && git merge --no-ff 0258ffd81f0ee054709dc272919c75c2db3daf40

node scripts/docs-audit/affected-docs.mjs --json 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16

⚠️ 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 0c0d3b68e161afadb2b5bc9ce47ad53c4d15de16 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:所有按名字启动 flow 的入口(两种 trigger 路由、type: 'flow' endpoint、flow 类 action 的 REST 与 MCP 入口)启动前都用调用者自己的权限读一次交给运行的记录,读不到回 action 入口同一个 404 RECORD_NOT_FOUND,不产生运行记录。
  • 为什么改:维护者裁决「A+C 同意」:几个启动入口要问同一个问题、给同一个答复;原来只有 action 入口问了。
  • 风险与回滚:调用者能读的记录、不带记录的启动、进程内系统身份都不变。新增拒绝:读不到/不存在 404;系统身份 flow 带记录却没给对象 400;记录键不是单个 id 400。用户身份 flow 给了对象时也检查(与 action 入口一致)。回滚即撤销本 PR。
  • 先看:action-execution.ts 的 refuseUnreadableFlowStartRecords、flowActionCallerRecordKeys;domains/automation.ts 的 respondToFlowTrigger、flowStartRecordKeys;endpoint-executor.ts 的 executeFlow。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/flow-start-record-admission.test.ts(32 例全绿)。
  • 需要你做的:无,席位落地。

席位意见:与 diff 相符。三个开放问题:用户身份 flow 也检查(取 A,入口一致是本卡要点,action 入口本就对每个 flow 做这次读取);Clause-②: no 保留(无 packages/spec 改动,收的是运行时入口的意外放行);flow 自声明记录对象的后续由席位按裁决 C 经 pm-dispatch 处理,不另开公开卡。PR 只写规则,不含复现。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T09:53Z · head 2020b6e5 · #29050

  1. Shape: draft against main, first line Fixes #29050, no other card number beside a closing keyword; patch changeset for @objectstack/runtime; Clause-②: no, Metadata-protocol: none, matching the claim. Not governed, +749 / -15, 15 files: 4 runtime sources, 9 tests (1 new pin file, 8 forced fixture edits), the changeset and one docs row. No packages/spec file changed.
  2. Against the order: one function, refuseUnreadableFlowStartRecords, sits beside the action door's subject load it reuses (loadActionSubjectRecord + refuseDeniedSubjectLoad) and is called after flowStartRefusal and before execute() at all four entrances (both trigger spellings via respondToFlowTrigger, executeFlow, dispatchFlowAction). The action door's routed-record load is unchanged; the refusal is its own 404 RECORD_NOT_FOUND; flow-start-admission.ts still imports nothing from action-execution.ts. Every refusal is pinned with execute never called. Read in the diff. Rule-only wording in the PR body, changeset and docblocks.
  3. Accepted deviations: content/docs/permissions/system-context.mdx row 57, forced by check:system-context-census (the new function reads isSystem). Eight fixture edits, each now serving the launched record, no assertion changed; named in the body. Two commit titles/bodies left as pushed (no force-push on a published head).
  4. Gates: dev reports 93/93 derived gates exit 0 on 2020b6e5 (0 NOT-MEASURED), runtime full suite 8350 passed on 5fda11a5, typecheck and scoped eslint exit 0; six ablations, each red then restored. Check-runs on this head were still running at ACCEPT; the seat readies it only after Lint & Repo Gates and TypeScript Type Check read success and no check is red.
  5. Out of scope: a flow-level declaration of the record object it addresses would be a packages/spec change; it is handled by the seat under the maintainer's ruling C, not on this card. Further notes were handed to the seat privately.
  6. Landing: pr_ready + automerge_enable through the relay once CI is green; the second of feat(automation): an oauth2 connector with clientSource: 'installer' connects with the administrator's OAuth client #29097 / fix(runtime): list_actions marks revoke_api_key unavailable to an API-key caller, with the API-key guard's own refusal (#28943) #29036 to land resolves against this one.

…ecord refusal

The trigger route now answers 404 RECORD_NOT_FOUND for a start whose record
the caller cannot read (and 400 VALIDATION_FAILED for a record key that is not
one id, or an elevated start with no object), before anything is dispatched.
The table, its outcome count and the never-dispatched count say so.

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

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:同 6574157736;新 head 只多一处文档:content/docs/automation/flows.mdx 的 trigger 结果表加一行规则式 404 RECORD_NOT_FOUND(并写明两种 400),「七种结果」改为八种。
  • 为什么改:文档要按入口现在的答复描述;docs-drift 提示指向此页。
  • 风险与回滚:纯文档,代码与测试未变。回滚即撤销本 PR。
  • 先看:flows.mdx 的 "Run a flow via API" 一节。
  • 两分钟验证:同上一记录(flow-start-record-admission.test.ts 32 例)。
  • 需要你做的:无,席位落地。

席位意见:文档行只写规则,不含复现。PR 正文仍引 2020b6e5 / 15 个文件,新 head 的读数在卡上 6574680700,席位接受,不改正文。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T10:20Z · head 0258ffd8 · #29050 (supersedes 6574157736 at 2020b6e5)

  1. Shape: unchanged from 6574157736, plus one docs file: +755 / -20 over 16 files. Fast-forward 2020b6e5..0258ffd8, docs only.
  2. Against the order: the new row states the refusal as the door's rule (one answer for unreadable and nonexistent, the 400 cases named), and the count lines follow it; read in the diff.
  3. Accepted deviations: as 6574157736, plus content/docs/automation/flows.mdx (the page that classifies the trigger route's outcomes). PR body readings stay at 2020b6e5; the card's delta report 6574680700 carries the new head.
  4. Gates: dev reports the same 93 derived commands exit 0 on 0258ffd8, --ran 0 NOT-MEASURED; code readings and ablations stand (no code change). The seat readies it only after Lint & Repo Gates and TypeScript Type Check read success on this head and no check is red.
  5. Out of scope: as 6574157736.
  6. Landing: pr_ready + automerge_enable through the relay once CI is green.

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 size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

automation: the trigger door starts an elevated screen flow on a recordId the caller cannot read — the action door refuses the same row (#16370)

1 participant