--- frozen: true step: AIP-a model: "Thinking (thinking-high)" date: 2026-08-24 branch: feat/automation-ingest-policy-a status: thinking-freeze-2026-08-24 supersedes: "Phase C + Lane D remain the machine credential and propose-path contract. This freeze adds per-account ingest rules so cron agent writes are not forced into Review as generic proposals. Does not authorize AIP-b Auto until freeze-review pass. Does not edit Scooling. Does not wire VideoFactory (AIP-c). Does not flip evaluation policy, billing enforce, or enable Born Free templates globally." evidence: "Knowtation PRIMARY 2026-08-24 after KN-AUTH-LANE-D propose-path land. Cron agents with kt_agent_ still land every write in Review. Born Free / VideoFactory trend scout is the first integration test, not a hard-coded Hub hack." --- # AUTOMATION-INGEST-POLICY — per-account ingest rules **Ground truth** for AIP-b Auto. Downstream Auto may treat this document as ground truth without re-deriving. Phase C (`docs/DURABLE-AGENT-AUTH-PHASE-C-FREEZE.md`) remains the token-shape and mint/exchange contract. Lane D remains the health + isolation + one machine path contract. This freeze does **not** edit Scooling, change SEC-SEAM / personal self-apply / T5 fingerprints, grant `vault:write` on the cron JWT, let `agent_access` call `POST api/v1/proposals/:id/approve`, bypass elevated triggers or `auto_flag_reasons`, enable Born Free templates on every Hub, or implement VideoFactory (AIP-c). ```yaml phase: AIP-a outputs: - id: automation-ingest-policy path: docs/AUTOMATION-INGEST-POLICY-FREEZE.md frozen: true notes: Per-sub ingest rules (match + disposition). Router first-match. POST api/v1/automation/ingest. Scope ingest:automation. direct_note is server-trusted write. proposal_auto_apply is a server hook, not approve. Born Free pack disabled. REST-only v1. frozen_inputs: - id: agent-credential-core path: hub/lib/agent-credential-core.mjs notes: ALLOWED_AGENT_SCOPES, DEFAULT_AGENT_SCOPES, applyScopeCeiling, agentScopesPermitMethod, PROPOSE_CREATE_PATHS, JWT cid + agent claims at exchange - id: access-token-authz path: hub/gateway/access-token-authz.mjs notes: subFromVerifiedPayload, isAgentAccessPayload, isSessionBoundActor, roleFromMcpAccessScopes, mayApplyAdminAllowlistOverride - id: gateway-get-user-id path: hub/gateway/server.mjs notes: getUserId uses effectiveRequestPath so propose allowlist sees api/v1/proposals not the Express suffix - id: request-path path: hub/gateway/request-path.mjs notes: effectiveRequestPath is the only path string Auto may pass to agentScopesPermitMethod on gateway and self-hosted ingest/hook routes - id: review-triggers path: lib/hub-proposal-review-triggers.mjs notes: applyReviewTriggers; elevated + auto_flag_reasons; packaged default + data/ override - id: personal-self-apply path: lib/hub-proposal-personal-self-apply.mjs notes: roleEligibleForPersonalSelfApply refuses agent_access; T5 fingerprints unchanged - id: proposal-create-augment path: lib/hub-proposal-create-augment.mjs notes: stripClientEvaluationFields + triggers + E1 after triggers - id: proposal-lifecycle path: docs/PROPOSAL-LIFECYCLE.md notes: States, roles, personal self-apply, review_queue / review_severity / auto_flag_reasons - id: phase-c-freeze path: docs/DURABLE-AGENT-AUTH-PHASE-C-FREEZE.md notes: propose is not approve; propose path allowlist; default scopes propose + vault:read; consumer trend-agent is follow-on - id: audit-log path: hub/audit-log.mjs notes: Existing JSONL hub_audit.log; AIP extends actions, does not invent a second log family - id: billing-middleware path: hub/gateway/billing-middleware.mjs notes: note_write vs proposal_write; ingest handler chooses op after route - id: list-notes path: lib/list-notes.mjs notes: filterNotesByListOptions; content_class filter is additive - id: hub-provenance path: lib/hub-provenance.mjs notes: mergeProvenanceFrontmatter kind agent already exists - id: proposals-store path: hub/proposals-store.mjs notes: createProposal + updateProposalStatus for self-hosted auto-apply mark review_stamp: reviewed_at: '2026-08-24T14:05:38Z' verdict: pass reviewer_mode: agent reviewer_model: thinking-high reviewer_provider: local kit_version: 0.1.0 artifact_digest: sha256:9fded978386543865225f5cc2bc0f04f09e777f8de96a56ef5d516c995a2793c downstream: - id: AIP-b model: Auto consumes_as_ground_truth: true notes: Implement router, ingest route, scope, Settings CRUD, Research filter, audit, seven-tier tests. Starts only after freeze-review pass. Auto does not edit Scooling. Auto does not enable Born Free templates. Auto does not claim production smoke. - id: AIP-c model: Thinking → Auto consumes_as_ground_truth: true notes: VideoFactory trend-scout wire. Not this Hub Auto. Appendix A is the contract edge only. tier3_gates: - T1 Muse main or muse-mirror to GitHub main (SD-14) outside SD-21 land hygiene - T2 Production ingest smoke on api.knowtation.store (Operator; record PASS or FINDINGS) - T3 Enabling packaged Born Free templates as enabled-true in the default JSON (global opt-in) - T4 Changing SEC-SEAM / personal self-apply / T5 fingerprints or admitting agent_access to E1 - T5 Granting vault:write on DEFAULT_AGENT_SCOPES or requiring vault:write for cron ingest - T6 Teaching agent_access to call POST api/v1/proposals/:id/approve - T7 Flipping BILLING_ENFORCE or inventing unscoped long-lived API keys - T8 Editing Scooling or VideoFactory from this Knowtation tip - T9 Feature branch to GitHub main / non-muse-mirror head ``` Auto must not build until freeze review **pass**. This Thinking tip does **not** implement routes. This Thinking tip does **not** flip any env. ## Review record | Round | Reviewer | Verdict | Resolution | | --- | --- | --- | --- | | 0 | Thinking (this session) | draft | Freeze authored from Phase C + Lane D + review triggers + self-apply + getUserId propose-path + billing + list-notes | | 1 | Freeze-review loop (thinking) | findings | R1-F1–F7 fixed below. Mechanical dry-run was already pass. | | 2 | Freeze-review loop (thinking) | findings | R2-F1–F3 fixed below. | | 3 | Freeze-review loop (thinking) + `ok review --freeze` + `ok check-ok --path` | **pass** | R1–R2 hold. Interfaces, fail-closed, seven-tier matrix, Tier-3 gates present. No open design decisions for Auto. No escalating category. Digest in `review_stamp.artifact_digest`. Cleared for AIP-b Auto. Auto must not edit Scooling, enable Born Free templates, or claim production smoke. | ### Round 1 findings (cited — file+line) | ID | Sev | Cat | Citation | Finding | Fix | | --- | --- | --- | --- | --- | --- | | R1-F1 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:465 (prior “Auto may call”) | Hosted auto-apply mark was unbound. Auto could invent Motoko or skip status flip. | D23 locks gateway `fetch` to canister approve with gateway auth only; D23 failure code. | | R1-F2 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:443 (prior “override or helper”) | Billing hook was an “or”. Auto could double-charge or skip storage cap. | D24 locks `opts.operation` on `runBillingGate`. | | R1-F3 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:348 (prior store-or-frontmatter) | `content_class` persist unbound. Auto could edit `proposals-store` ad hoc. | D25 frontmatter only. | | R1-F4 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:450 (prior “GET existing note”) | D22 I/O unbound. | D22 names `readNote` and canister GET. | | R1-F5 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:434 (prior execute “may”) | Policy vs I/O split unbound; D26 later conflicted. | D26 pure exports; execute in route wrappers. | | R1-F6 | MINOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:574 (prior PATCH sugar) | Auto could add PATCH. | PATCH forbidden in v1. | | R1-F7 | MINOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:640 (prior skip audit) | Hosted audit could no-op. | Same `dataDir` as augment; always append. | ### Round 2 findings (cited — file+line) | ID | Sev | Cat | Citation | Finding | Fix | | --- | --- | --- | --- | --- | --- | | R2-F1 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:226 (prior D26 store-only) | Idempotency persist names unbound. Auto could invent a third file. | D26 names `getIngestIdempotency` / `putIngestIdempotency` on the store module. | | R2-F2 | MAJOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:748 (prior “if a new blob”) | Netlify blob globals unbound. Auto could reuse billing/agent blobs. | D27 two dedicated blobs + globals. | | R2-F3 | MINOR | completeness | docs/AUTOMATION-INGEST-POLICY-FREEZE.md:729-736 (prior “if needed”) | File list still had optional edits. | access-token-authz and audit-log are do-not-edit; D8 hook is required. | ## Citation discipline Every freeze-review finding MUST cite **file+line** (OVERSEER-KIT-SPEC §6). Do not trust uncited review output. HTTP routes in this doc omit the leading slash (`api/v1/…`) so the freeze mechanical gate does not treat them as absolute machine paths. Cross-repo paths use `~/scooling/…`. Never leading-slash absolute paths. --- ## 1. Plain-language summary Robots with `kt_agent_` credentials can already create proposals. Every one of those writes still lands in Review as a generic proposal. Operators need a Settings page of **ingest rules**: for matching robot writes, choose **write the vault note now**, **create a proposal and apply it on the server**, or **leave it in Review** (today’s default). Elevated legal/security triggers still win. Robots never get a blanket vault-write password. Born Free is a **disabled template pack** the operator turns on for one account — not three rules baked into every Hub. ### Technical summary AIP adds `ingest:automation` to the Phase C scope vocabulary; a first-match per-sub rule router; `POST api/v1/automation/ingest` as the preferred cron entrypoint; a required agent-only hook on `POST api/v1/proposals` when the body matches the ingest contract (D8); server-trusted `direct_note` (canister or `writeNote`, billing `note_write`); `proposal_auto_apply` as create-then-server-apply (never the approve HTTP route; never E1); `review_queue` via existing augment + triggers; `content_class` list filter; JSONL audit actions on `hub_audit.log`. MCP is **out of v1**. REST only. --- ## 2. Ground truth — what the code does today (file+line) Every row was read in this session. | # | Fact | Citation | | --- | --- | --- | | G1 | Allowed agent scopes are `vault:read`, `propose`, `vault:write` only | `hub/lib/agent-credential-core.mjs:27` | | G2 | Default mint scopes are `propose` + `vault:read` | `hub/lib/agent-credential-core.mjs:29` | | G3 | `applyScopeCeiling` always keeps `propose`; `vault:write` needs role write | `hub/lib/agent-credential-core.mjs:110-116` | | G4 | `propose` without write allows only three POST paths | `hub/lib/agent-credential-core.mjs:411-433` | | G5 | `vault:write` (or admin) short-circuits `agentScopesPermitMethod` to allow all | `hub/lib/agent-credential-core.mjs:427-429` | | G6 | Exchange JWT already carries `cid` and `agent` (credential name) | `hub/gateway/agent-credential-routes.mjs:226-236` | | G7 | `subFromVerifiedPayload` enforces aud, typ, and `agentScopesPermitMethod` | `hub/gateway/access-token-authz.mjs:173-178` | | G8 | `isSessionBoundActor` is true only for `type: session` | `hub/gateway/access-token-authz.mjs:69-70` | | G9 | Gateway `getUserId` passes `effectiveRequestPath` (Lane D propose-path fix) | `hub/gateway/server.mjs:1959-1967` | | G10 | Gateway catch-all `app.use('api/v1')` bills then `proxyToCanister` | `hub/gateway/server.mjs:4466-4469` | | G11 | Self-hosted `jwtAuth` verifies JWT only — no `agentScopesPermitMethod` | `hub/server.mjs:395-406` | | G12 | Self-hosted `POST api/v1/proposals` inlines triggers; does not call `augmentProposalCreateRequestBody` | `hub/server.mjs:3194-3231` | | G13 | Hosted create runs `augmentProposalCreateForHosted` → augment + E1 | `hub/gateway/server.mjs:3790-3808` | | G14 | `applyReviewTriggers` sets `forcePending`, queue, severity, `auto_flag_reasons` | `lib/hub-proposal-review-triggers.mjs:119-165` | | G15 | E1 runs after triggers; elevated or auto-flag stay pending | `lib/hub-proposal-create-augment.mjs:78-84` | | G16 | `roleEligibleForPersonalSelfApply` refuses `tokenType === 'agent_access'` | `lib/hub-proposal-personal-self-apply.mjs:437-440` | | G17 | Hosted approve path refuses agents via `humanActor: false` + `tokenType: agent_access` | `hub/gateway/server.mjs:3575-3616` | | G18 | Phase C denies approve/discard/notes write even with `propose` | `docs/DURABLE-AGENT-AUTH-PHASE-C-FREEZE.md:330` | | G19 | Billing maps `POST api/v1/notes` and `PUT` notes to `note_write`; proposals to `proposal_write` | `hub/gateway/billing-middleware.mjs:26-40` | | G20 | `filterNotesByListOptions` has no `content_class` key | `lib/list-notes.mjs:57-119` | | G21 | Vault filter `filter-content-scope` is notes vs approval logs only | `web/hub/index.html:180-184` | | G22 | Audit append is JSONL on `hub_audit.log` | `hub/audit-log.mjs:18-30` | | G23 | Provenance `kind` already includes `agent` | `lib/hub-provenance.mjs:37` | | G24 | Self-hosted approve writes via `writeNote`; store can `updateProposalStatus` | `hub/server.mjs:3378`; `hub/proposals-store.mjs:353-368` | | G25 | Canister note IO uses `x-user-id`, `x-actor-id`, `x-vault-id`, plus `canisterAuthHeaders()` | `hub/gateway/server.mjs:3663-3670` | | G26 | Settings tabs today: backup, team, vaults, integrations, appearance, billing, consolidation — no Automation tab | `web/hub/index.html:705-713` | | G27 | Mint UI checkboxes: propose, vault:read, vault:write | `web/hub/index.html:1448-1450` | | G28 | Phase C consumer contract still posts `api/v1/proposals` (not ingest) | `docs/DURABLE-AGENT-AUTH-PHASE-C-FREEZE.md:381` | | G29 | PROPOSAL-LIFECYCLE says agents create `proposed` rows via `POST api/v1/proposals` | `docs/PROPOSAL-LIFECYCLE.md:18` | **Consumer evidence (operator-reported, not in this workspace):** Born Free / VideoFactory trend scout posts Hub proposals after `kt_agent_` exchange. Lane D unblocked `POST api/v1/proposals`. Those rows still queue as generic Review items. Trend-agent source is not a Knowtation tree. AIP-b does **not** edit VideoFactory. Appendix A freezes the Hub-side contract edge only. --- ## 3. Frozen product goal An operator signed into Hub can: 1. Open **Settings → Automation → Ingest rules**. 2. See packaged templates (Born Free pack) as **disabled**. Enable or copy a template for **this account only**. 3. Order rules by **priority** (lower number wins). First matching **enabled** rule decides the write. 4. Choose per rule: **direct vault note**, **server auto-apply proposal**, or **Review queue**. 5. Mint a cron credential with **`ingest:automation` + `vault:read`** (no `vault:write`). 6. Point the robot at `POST api/v1/automation/ingest` (preferred). Existing `POST api/v1/proposals` still works for humans; agents with an ingest-shaped body may hit the same router. 7. Filter Vault by **Research** (`content_class=research`). Success metric: a matching Born Free trend write can land as a research note or an auto-applied proposal **only after the operator enables a rule**; unmatched or flagged writes stay in Review; `agent_access` still cannot approve. --- ## 4. Decisions (lock — do not reopen in Auto) | ID | Decision | Recorded default | | --- | --- | --- | | **D1** | Router | **First-match.** Sort enabled rules by `priority` ascending, then `rule_id` lexicographic. First AND-match wins. No specificity score. | | **D2** | Default disposition | No enabled match → `review_queue` (today’s behavior). | | **D3** | Match empty | Forbidden. At least one predicate required. Blank match is `400` `INGEST_RULE_MATCH_EMPTY`. | | **D4** | Match combinator | All specified predicates **AND**. Unspecified keys are wildcards. | | **D5** | Scope | New `ingest:automation`. Additive like `propose` in `applyScopeCeiling`. Not in `DEFAULT_AGENT_SCOPES`. | | **D6** | Cron mint | Recommended `ingest:automation` + `vault:read`. Checkbox default **off**. Never require `vault:write`. | | **D7** | Ingest path allowlist | `POST` + normalized path `api/v1/automation/ingest` requires `ingest:automation` (or `vault:write`, existing short-circuit). `propose` alone does **not** open the ingest route. | | **D8** | Legacy proposals hook | **On** for `agent_access` only when the body matches the ingest contract (D14). Session / human callers never enter the router. | | **D9** | Elevated / flags | After match, **before** execute, run `applyReviewTriggers`. If `forcePending` or `review_severity === 'elevated'` or `auto_flag_reasons.length > 0` → force `review_queue` and audit `ingest_elevated_override`. All three dispositions. | | **D10** | Evaluation vs auto-apply | If evaluation policy would leave `evaluation_status` `pending` (E1 does not apply to agents — G15, G16) → **cannot** `proposal_auto_apply`; fall back to `review_queue`. Do **not** invent ingest-E1. Do **not** stamp `evaluation_status: passed` for robots. | | **D11** | Approve HTTP | `agent_access` never calls `POST api/v1/proposals/:id/approve`. Auto-apply uses `writeNote` / canister note POST + `updateProposalStatus` / hosted status flip **without** forwarding the agent Bearer to approve. | | **D12** | direct_note vs JWT | Server-trusted write only. JWT does not need `vault:write`. | | **D13** | MCP | **REST-only v1.** No MCP tool, prompt, or resource. | | **D14** | Ingest contract body | See §6.2. Required `source_fingerprint`. Marker is `ingest === true` **or** a known `content_class`. | | **D15** | Idempotency precedence | If header `X-Ingest-Idempotency-Key` is non-empty after trim → that is the store key. Else key = `source_fingerprint`. See §8. | | **D16** | Born Free pack | Packaged templates, all `enabled: false`. Never copy into a new sub as enabled. Operator enable is per-sub CRUD. | | **D17** | Settings chrome | New Settings tab **Automation** (`data-settings-tab="automation"`). Ingest rules live there. Pointer line under Integrations → Agent credentials. | | **D18** | Rule cap | Max **32** enabled+disabled user rules per sub (templates in the pack do not count until copied). | | **D19** | Self-hosted agent auth | Ingest route and the proposals hook **must** call `agentScopesPermitMethod` with `effectiveRequestPath(req)` from `hub/gateway/request-path.mjs` (G11 is a gap). Do not rewrite all of `jwtAuth` in this Auto. | | **D20** | Hosted mount | Dedicated `app.post` for ingest **before** the catch-all at `hub/gateway/server.mjs:4466`. | | **D21** | Scooling | No edits. No T5 fingerprint changes. | | **D22** | Path clobber | `direct_note` / auto-apply write to an existing path is allowed only on idempotent replay (same fingerprint) or if existing frontmatter `source_fingerprint` equals the request. Else `409` `INGEST_PATH_CONFLICT`. Self-hosted existence check is `readNote` in `lib/vault.mjs`. Hosted existence check is `GET` canister `api/v1/notes/` with the same headers as G25. Missing note is not a conflict. | | **D23** | Hosted auto-apply mark | Gateway `fetch`es `${CANISTER_URL}` + `api/v1/proposals//approve` with `POST`, `canisterAuthHeaders()`, `x-user-id`, `x-actor-id`, `x-vault-id`, JSON `{}`. **No** inbound agent `Authorization` header is copied. Incoming agent HTTP to that path still hits `assertHostedProposalApproveDiscard` (G17). If this mark fails after the note write, respond `500` `INGEST_APPLY_FAILED` with `proposal_id` + `path`; do **not** delete the note; audit `ingest_apply_failed`. | | **D24** | Billing hook | Extend `runBillingGate` with `opts.operation`. When set, that string replaces `operationFromRequest`. Ingest calls it **after** route: `proposal_write` for §9.1; `note_write` for §9.2 and §9.3. When `opts.operation === 'note_write'`, also run the existing storage-cap branch (`getNoteCount`). Do **not** add a path regex for ingest that always maps to `note_write`. | | **D25** | `content_class` persist | Stamp on **frontmatter only** (note and proposal). Do **not** add a `proposals-store` column. List filter reads `n.content_class ?? n.frontmatter?.content_class`. Response `content_class` is computed. | | **D26** | Module exports | `lib/automation-ingest-policy.mjs` (pure) exports `routeAutomationIngest`, `isIngestContractBody`, `normalizeIngestBody`, `listPackTemplates`. `hub/gateway/automation-ingest-store.mjs` exports `loadIngestRulesForSub`, `saveIngestRulesForSub`, `getIngestIdempotency`, `putIngestIdempotency` (file + blob). Execute I/O stays in `hub/gateway/server.mjs` and `hub/server.mjs` wrappers — not in the pure module. | | **D27** | Netlify blobs | `netlify/functions/gateway.mjs` provisions **two** new dedicated blobs: `gateway-automation-ingest-rules` → `globalThis.__knowtation_gateway_ingest_rules_blob` and `gateway-automation-ingest-idempotency` → `globalThis.__knowtation_gateway_ingest_idempotency_blob`. Never reuse `gateway-agent-credentials` or `gateway-billing`. | --- ## 5. Rule schema (frozen) ### 5.1 Rule object ```json { "rule_id": "ingr_<16 hex>", "enabled": false, "priority": 100, "pack_id": null, "label": "Trend scout → Review", "match": { "credential_id": null, "credential_name": null, "credential_name_prefix": null, "path_prefix": "inbox/trends/", "intent": null, "content_class": "research" }, "disposition": "review_queue", "content_class": "research" } ``` | Field | Type | Rules | | --- | --- | --- | | `rule_id` | string | Server-minted `ingr_` + 16 lowercase hex. Client cannot supply on create. | | `enabled` | boolean | Disabled rules are skipped by the router. | | `priority` | integer | `0`–`10000`. Lower wins. Default `100`. | | `pack_id` | string or null | `born_free_v1` when copied from the pack; else null. | | `label` | string | 1–128 chars. Required. | | `match` | object | At least one non-null predicate (D3). | | `disposition` | enum | Exact: `direct_note` \| `proposal_auto_apply` \| `review_queue`. | | `content_class` | enum or null | Stamp when request omits class. Closed set §7. | ### 5.2 Match predicates | Key | Match | Source | | --- | --- | --- | | `credential_id` | exact, case-sensitive | JWT claim `cid` (G6). If claim missing → predicate fails (no store lookup required in v1). | | `credential_name` | exact, case-sensitive | JWT claim `agent` (G6). Missing claim → fail. | | `credential_name_prefix` | `agent` starts with prefix (case-sensitive) | Same claim. | | `path_prefix` | `notePathMatchesPrefix` (`lib/write.mjs:49-52`) | Request `path`. Normalize slashes; no leading slash. | | `intent` | exact | Request `intent`. | | `content_class` | exact | Request `content_class` after defaulting (§7). | Null / omitted / empty-string predicates are skipped (wildcard). After skip, **zero** remaining predicates → invalid rule (D3). ### 5.3 Disposition enum | Value | Meaning | | --- | --- | | `direct_note` | Server writes the note. No Review row unless D9/D22 forces queue. | | `proposal_auto_apply` | Create proposal (augment + triggers) then server-apply if D9 and D10 allow. | | `review_queue` | Create proposal only. Today’s path. | Unknown disposition on save → `400` `INGEST_DISPOSITION_UNKNOWN`. --- ## 6. Router (frozen) Export **`routeAutomationIngest(input, rules)`** from `lib/automation-ingest-policy.mjs`. Pure. No I/O. ### 6.1 Algorithm 1. Take `rules` for `input.sub` only (caller loads storage). 2. Drop `enabled !== true`. 3. Sort by `priority` ascending, then `rule_id` ascending (UTF-8). 4. For each rule, evaluate §5.2 AND. First true → candidate. 5. If none → candidate `{ rule_id: null, disposition: 'review_queue', content_class: input.content_class }`. 6. Run `applyReviewTriggers` on `{ path, body, intent, labels }` (G14). If D9 trips → rewrite disposition to `review_queue`, set `elevated_override: true`, keep `rule_id` of the candidate (or null). 7. If candidate disposition is `proposal_auto_apply` and D10 trips (`evaluationRequired === true` and E1 will not stamp — agents never session-bound) → rewrite to `review_queue`, set `evaluation_block: true`. 8. Return `{ rule_id, disposition, content_class, elevated_override, evaluation_block, trigger_result }`. `content_class` on the result: request class if known; else rule `content_class`; else `general`. ### 6.2 Ingest request body (preferred route) `POST api/v1/automation/ingest` **Auth:** `agent_access` with `ingest:automation` (D7) **or** session `editor`/`admin` (operator test from Settings). Vault: `X-Vault-Id` must pass `assertAgentVaultAllowed` for agents (Phase C §7.4). ```json { "path": "inbox/trends/example.md", "body": "markdown", "frontmatter": {}, "intent": "videofactory.trend_scout.ingest", "labels": [], "source": "automation_ingest", "source_fingerprint": "sha256:…-or-opaque-8-to-128", "content_class": "research", "ingest": true } ``` | Field | Required | Rules | | --- | --- | --- | | `path` | yes | Vault-relative. No `..`. No leading slash. Max 512. Must end `.md`. | | `body` | yes | String. Max 512 KiB. | | `frontmatter` | no | Object. Reserved provenance keys stripped (`lib/hub-provenance.mjs`). | | `intent` | no | String max 256. | | `labels` | no | String array, max 32, each max 64. | | `source` | no | Default `automation_ingest`. Max 64. | | `source_fingerprint` | **yes** | Trimmed 8–128 chars. Charset `[A-Za-z0-9._:/-]`. | | `content_class` | no | §7. Unknown → `400` `INGEST_CONTENT_CLASS_UNKNOWN`. | | `ingest` | no | Boolean. Ignored on the ingest route (always ingest). Required as a marker on the legacy hook unless `content_class` is known (D14). | **Headers** | Header | Required | Rules | | --- | --- | --- | | `Authorization` | yes | Bearer session or `agent_access`. | | `X-Vault-Id` | no | Default `default`; must be in `vault_ids` for agents. | | `X-Ingest-Idempotency-Key` | no | Trimmed 8–128. Same charset as fingerprint. Precedence D15. | ### 6.3 Success response `201` on first apply/create. `200` on idempotent replay (`replayed: true`). ```json { "disposition": "review_queue", "rule_id": "ingr_ab12cd34ef567890", "outcome": "proposal", "path": "inbox/trends/example.md", "content_class": "research", "proposal_id": "prop-…", "note": null, "replayed": false, "elevated_override": false, "evaluation_block": false } ``` | `outcome` | When | | --- | --- | | `note` | `direct_note` write succeeded | | `proposal` | Review row created (queue or auto-apply-created) | | `note_and_proposal` | `proposal_auto_apply` wrote the note and marked approved | `proposal_id` required when a proposal exists. `note` is `{ "path": "…" }` when a note was written. ### 6.4 Error codes (closed) | HTTP | `code` | | --- | --- | | 400 | `INGEST_PATH_INVALID`, `INGEST_BODY_REQUIRED`, `INGEST_FINGERPRINT_REQUIRED`, `INGEST_FINGERPRINT_INVALID`, `INGEST_CONTENT_CLASS_UNKNOWN`, `INGEST_CONTRACT_REQUIRED` (legacy hook body mismatch — should not appear on the ingest route), `INGEST_RULE_MATCH_EMPTY`, `INGEST_DISPOSITION_UNKNOWN` | | 401 | `UNAUTHORIZED` | | 403 | `AGENT_VAULT_FORBIDDEN`, `FORBIDDEN` | | 409 | `INGEST_IDEMPOTENCY_CONFLICT`, `INGEST_PATH_CONFLICT` | | 402 | billing codes from `runBillingGate` unchanged | | 429 | existing agent rate limit if any | | 500 | `INGEST_APPLY_FAILED` (D23 only) | | 503 | `AGENT_CREDENTIAL_STORE_UNAVAILABLE` / store I/O on rules or idempotency | Do not invent extra codes in Auto. ### 6.5 Legacy `POST api/v1/proposals` hook (D8) Runs **only** when **all** hold: 1. Verified payload `type === 'agent_access'` (G8 inverse). 2. Method `POST` and normalized path is exactly `api/v1/proposals` (not `…/approve`, not task/flow facades). 3. Body matches ingest contract (D14): non-empty valid `source_fingerprint` **and** (`ingest === true` **or** known `content_class`). Then the handler calls the same router + execute as the ingest route (scope already allowed via `propose` on this path — G4). Response **shape** is the ingest success/error envelope (not the raw `createProposal` object) so cron clients can share a parser. HTTP status 201/200 as §6.3. If (1) or (3) fail → **existing** create path unchanged (G12 / G13). Session callers with `source_fingerprint` set still use existing create. That is intentional (do not hijack Hub UI / CLI humans). Task / media / flow / path facade POSTs are **not** ingest. Do not hook them. --- ## 7. `content_class` + Research filter Closed enum (lowercase): | Value | Meaning | | --- | --- | | `research` | Research / scout notes. Vault filter **Research**. | | `ops` | Operational automation. | | `general` | Default when omitted. | Stamp: - Note frontmatter key `content_class` (string). Server sets; client cannot override reserved provenance but **can** send `content_class` (not in RESERVED — G23). Server overwrites with the routed class. - Proposal `frontmatter.content_class` only (D25). Do **not** invent a store column. Do **not** add a fake `research` label unless the client sent that label. **List filter:** add `content_class` to `filterNotesByListOptions` (G20). Exact match on `n.content_class ?? n.frontmatter?.content_class`. Query `GET api/v1/notes?content_class=research`. **Hub UI:** new `