Proposal lifecycle (Hub)
This document defines states, roles, and identifiers for Knowtation Hub proposals. It is the reference for extending ProposalRecord (Node, canister, OpenAPI) without drift.
States
| Status | Meaning |
|---|---|
proposed |
Waiting for review; not applied to the canonical vault. |
approved |
Applied to the vault; proposal record kept for audit/history. |
discarded |
Rejected; not applied. May be bulk-discarded when notes are deleted (see HUB-METADATA-BULK-OPS.md). |
Allowed transitions:
proposed→approved(admin / permitted evaluator Approve; or personal self-apply for the Scooling review-tray class — see below; may require human evaluation first — see Evaluation)proposed→discarded(admin: Discard, or bulk housekeeping)
There is no draft status in the store today; agents create proposed rows via POST /api/v1/proposals. Agent ingest may skip Review only via AIP rules (POST api/v1/automation/ingest or the agent-only ingest-shaped proposals hook). Approve HTTP remains denied for agent_access. Elevated review triggers still apply. See AUTOMATION-INGEST-POLICY-FREEZE.md.
Roles (Phase 13 + evaluator)
| Role | List / view proposals | Create proposal | Submit evaluation | Approve / discard |
|---|---|---|---|---|
viewer |
Yes | No | No | No |
editor |
Yes | Yes | No | Yes only via personal self-apply class (below); otherwise No |
member (hosted) |
Yes | Yes | No | Yes only via personal self-apply class (below); otherwise No |
evaluator |
Yes | No | Yes | Only if HUB_EVALUATOR_MAY_APPROVE=1 (approve only; discard stays admin on Node Hub) |
admin |
Yes | Yes | Yes | Yes |
Discard remains admin-only on hosted gateway and Node Hub (personal self-apply never grants discard).
Optional Tier-2 enrichment (POST /api/v1/proposals/:id/enrich when KNOWTATION_HUB_PROPOSAL_ENRICH=1): editor or admin.
Personal self-apply (Scooling review tray — HOSTED-WRITE-EVAL)
For the Scooling personal self-apply class, the learner’s Scooling review-tray Approve is the human review. Knowtation does not require a separate Hub POST …/evaluation hop for that class, and hosted member (Node: editor) may POST …/approve when all of the following hold server-side:
| Check | Rule |
|---|---|
| Actor | JWT resolves; actor has vault:write (hosted member/admin, or Node editor/admin) |
| Partition | Proposal is in the actor’s effective vault partition (hosted: canister user partition; no cross-partition apply) |
| Intent | intent === "scooling.review_tray.approve" (exact) |
| External ref | external_ref matches ^scooling\.review:[A-Za-z0-9._:-]{1,200}$ |
| Path | path matches ^reviewed/[A-Za-z0-9._:-]{1,128}\.md$ |
| Not elevated | review_severity !== "elevated" and auto_flag_reasons / auto_flag_reasons_json empty or absent |
| Status | status === "proposed" |
| No learner waiver | Approve must not rely on waiver_reason for this class (Scooling omits it) |
Evaluation satisfaction (E1, preferred): On POST /api/v1/proposals create, after policy + review-trigger augmentation, when the fingerprint still matches and the proposal is not elevated/auto-flagged, the gateway / Node Hub sets evaluation_status to passed and records evaluated_by / evaluated_at from the creating actor. Elevated or auto-flagged proposals are not self-passed.
This is not a global “members may approve any proposal” grant. Non-matching intents, elevated content, org/classroom flows, and IDOR cross-partition attempts keep existing Hub RBAC / evaluation gates. Do not turn off HUB_PROPOSAL_EVALUATION_REQUIRED globally for this path.
Implementation: lib/hub-proposal-personal-self-apply.mjs; hosted gate in hub/gateway/server.mjs assertHostedProposalApproveDiscard.
Session-bound learner identity (SEC-SEAM-1)
Consumer contract: Consumers must present a per-learner, session-bound Knowtation credential on task, media, and delegation proposal-create surfaces. A shared service credential is a contract violation. Knowtation cannot detect it; its only server-side consequence is permanent ineligibility for personal self-apply.
Seam surfaces (task / media / delegation / flow / flow_capture) that can trigger an approve-time apply are classified by reusing the apply path’s own predicates. Self-apply on those surfaces requires a mint-stamped type: 'session' credential and authorActorId === approverActorId.
Tasks / Media / Flow admission (T5 + FLOW-WRITE-LIVE-KN-b)
Gate T5 admits personal-scope Tasks, Media, and Wave 1 Flow authoring into the self-apply class under positive fingerprints (not a global member approve). Frozen contracts: ~/scooling/docs/FINISH-COMPLETE-APPLY-CONTRACT.md §FCA.4 (Tasks/Media) and ~/scooling/docs/FLOW-WRITE-LIVE-FREEZE.md §FWL.4 (Flow).
| Surface | Fingerprint (approve-time) |
|---|---|
| Tasks | source/seam task classification; proposal_kind ∈ closed allowlist; path meta/tasks/proposals/{proposal_id}.json (never pending); external_ref ^scooling\.task:[A-Za-z0-9._:-]{1,200}$; body scope personal; task_assign only when assignee_ref equals session author |
| Media | source === media; kind ∈ {media_external_link,media_attach}; path meta/media/proposals/{proposal_id}.json; external_ref ^scooling\.media:[A-Za-z0-9._:-]{1,200}$; body scope === personal |
| Flow authoring | source === flow; flow_meta present as object and flow_meta.kind exactly ∈ {new,edit,import} (missing/empty kind ⇒ not admitted — do not default to new); path ^meta/flows/[A-Za-z0-9._:-]{1,128}\.md$; external_ref ^scooling\.flow:[A-Za-z0-9._:-]{1,128}$; scope personal (frontmatter.scope → body.flow.scope) |
| Delegation | Never — SELF_APPLY_DELEGATION_REFUSED (P4) |
| flow_capture | Seam-classified but not admitted — Wave 1 and Wave 2 (SELF_APPLY_NOT_ADMITTED; SD-7 / SD-23 never-auto; option B propose-only) |
| learning-path | Seam-classified (source === learning_path, review_queue === learning-path) but not admitted this Auto (SELF_APPLY_NOT_ADMITTED). path_create | path_update | path_archive stay off T5 / ADMITTED_* lists. Human approve → apply-approved hook. A valid scooling.path: external_ref is not T5 admission. |
Wave 2 Flow capture (FLOW-CAPTURE-LIVE-KN-b / CAPTURE-HOSTED-APPLY-KN-b / SD-23)
Capture proposals use source: flow_capture with kinds flow_candidate_promote |
flow_candidate_merge | flow_candidate_dismiss. They remain a seam surface
(SEC-SEAM-1) and stay out of T5 admission for the entire Wave 2 — even when
session-bound authorActorId === approverActorId. There is no positive
capture fingerprint helper in Wave 2. Personal self-apply returns
SELF_APPLY_NOT_ADMITTED (SD-23; consumer honesty = pending / Hub-complete).
Hub-complete apply is real (CAPTURE-HOSTED-APPLY-KN-b): when an admin or an
evaluator with approve permission approves a capture proposal on the hosted
gateway, the post-approve hook (maybeApplyHostedCaptureAfterApprove) MUST run the
shared precheckApprovedCaptureProposal + applyCaptureProposal pair via the
bridge POST api/v1/flows/capture/proposals/:proposal_id/apply-approved route —
promote upserts the Flow (visible on GET api/v1/flows), merge marks the candidate
merged_into:{id}, dismiss marks it rejected. Self-hosted approve keeps its
precheck-before-commit path (hub/server.mjs). Hosted approve commits before the
hook, so an apply failure yields an approved proposal with no Flow, honestly
surfaced as capture_index_applied: false on the approve response.
Wave 2 “propose-only” means no learner self-apply / no auto-promote — not
“approve never applies.” Gateway→bridge proxies exist for
observe/list/propose/dismiss (FCL-C10) plus apply-approved and Flow list/get
(CHA-C1–C5); Knowtation FLOW_CAPTURE_*_ENABLED posture is unchanged by this
phase. Hosted media apply still does not exist (see below).
Propose paths persist a validated optional external_ref (malformed → 400; absent → propose ok, not admitted). Flow propose accepts scooling.flow:… for all Wave 1 kinds; import lineage hints must not substitute for the admission ref. E1 create-time evaluation satisfaction widens to admitted Task/Media/Flow fingerprints when sessionBound + author gates hold. Client-supplied evaluation_status / evaluated_by / evaluated_at remain stripped (P2).
Honest notes review-tray proposals remain eligible under the fingerprint rules above.
Media proposals — hosted + self-hosted (SEC-SEAM-MEDIA / SEC-SEAM-1 S7)
- Hosted media propose/apply exists via gateway→bridge (
api/v1/attachments/link-proposals,attach-proposals,import-consents,proposals/:id/apply-approved, plus attachment list/get) when Hub gates allow (SEC-SEAM-MEDIA SM-C1–C7). - Seam classification: hosted canister rows use
normalizeCanisterProposalForMediaPrecheck(frontmatter markers); self-hosted rows usesource: media. Both are conditions ofisSeamSurfaceProposal— the normalize predicate is the same call the gateway post-approve hook uses (S3.0; no parallel kind/intent list). - Gates still default off:
MEDIA_EXTERNAL_LINK_ENABLED/MEDIA_ATTACH_ENABLEDremain default-off; production flips remain Tier 3 (SM-C10). - Hub-complete approve invokes
maybeApplyHostedMediaAfterApproveafter canister approve commits. Apply failure is non-fatal to approve status and surfacesmedia_index_applied: false(+media_apply_error/media_apply_code). Ops may re-call bridgeapply-approvedwhile status isapproved(SM-C12).
Optimistic concurrency: base_state_id
When a proposal targets an existing note path, the client may send base_state_id: a fingerprint of the vault note as the client last saw it (e.g. from GET /api/v1/notes/:path). On Approve, the Hub (self-hosted Node) recomputes the current fingerprint for that path and returns 409 CONFLICT if it does not match either the request body’s base_state_id (if provided) or the value stored on the proposal.
Format kn1_ (FNV-1a 64-bit)
- Prefix:
kn1_ - Suffix: 16 lowercase hex characters = FNV-1a 64-bit over UTF-8 bytes of
canonicalJSON(frontmatterObject) + "\0" + body canonicalJSONmeans sorted object keys at all levels (see lib/note-state-id.mjsstableStringify).
Absent path (new note; no file at that path yet): fingerprint is the FNV-1a of the single byte 0x00, still with prefix kn1_ (see absentNoteStateId()).
Hosted canister: Approve does not recompute kn1_ in Motoko today (frontmatter serialization may differ from Node). Clients should rely on Node Hub for strict checks, or treat base_state_id as advisory on canister-only flows until parity is implemented.
external_ref on approve (optional Muse bridge)
Besides setting external_ref at POST /api/v1/proposals (create), operators may set or resolve it at POST /api/v1/proposals/:id/approve: the client can send external_ref in the approve body, or the server may fill it from an optional Muse lineage callback when MUSE_URL is configured. Approve never fails because Muse is unreachable. See MUSE-THIN-BRIDGE.md.
Optional fields (augmentation)
| Field | Purpose |
|---|---|
intent |
Human- or agent-readable reason. |
external_ref |
Optional cross-system id (e.g. Muse lineage). |
labels |
String array for triage/filter (not only inside proposed frontmatter). |
source |
e.g. agent, human, import. |
suggested_labels, assistant_notes, assistant_model, assistant_at |
Tier-2 enrichment output when enabled. |
review_queue |
Optional string for triage (e.g. legal), set by deterministic triggers or client. Live Drive/Notion imports use docs-sync (KN-DOCS-SYNC). Proposals on that queue are not admitted to personal self-apply (no T5 fingerprint); human approve only. |
review_severity |
standard or elevated, from triggers. |
auto_flag_reasons |
String array of structured reason codes (e.g. phrase:…, path_prefix:…); audited on create when non-empty. |
review_hints, review_hints_at, review_hints_model |
Optional async LLM text for humans only; never the sole merge gate. |
Deterministic review triggers
Org-configurable rules in data/hub_proposal_review_triggers.json (override; packaged default hub/proposal-review-triggers-default.json) can force evaluation_status: pending and set review_queue / review_severity from:
literal_phrases— case-insensitive substring match on path + body + intent (bounded list/size per lib/hub-proposal-review-triggers.mjs).path_prefixes— vault-relative path prefix match.label_any— intersection with proposallabels.
Hosted: The gateway merges the same logic into POST /api/v1/proposals before the canister (see lib/hub-proposal-create-augment.mjs). Inline LLM review hints after create are skipped for this class (see hub/gateway/proposal-review-hints-async.mjs) so one-click approve is not held behind the hints deadline.
Human evaluation (Phase: proposal evaluation)
Evaluation is a human-led record (who/when/outcome/checklist/comment, optional grade). It is not the same as Tier-2 Enrich (LLM assist). LLM output must not be the sole merge authority.
evaluation_status (orthogonal to status)
| Value | Meaning |
|---|---|
none |
No evaluation required for this proposal (gate off at creation), or legacy row with no field set. |
pending |
Evaluation expected before approve (gate on at creation). |
passed |
Evaluator recorded a pass outcome; approve allowed without waiver. |
failed |
Evaluator recorded fail; approve blocked unless admin supplies a waiver (see below). |
needs_changes |
Evaluator requested changes; approve blocked unless waiver. |
Transitions (human action via POST /api/v1/proposals/:id/evaluation):
none→passed|failed|needs_changes(optional audit when gate is off)pending→passed|failed|needs_changesfailed|needs_changes→passed|failed|needs_changes(re-evaluation after edits)
approved / discarded proposals cannot receive new evaluations.
Stored fields (Node hub_proposals.json and canister ProposalRecord)
| Field | Purpose |
|---|---|
evaluation_status |
One of the values above. |
evaluation_grade |
Optional (e.g. letter or 1–5). Secondary to pass/fail. |
evaluation_checklist |
JSON array: [{ "id", "label", "passed" }] merged from org rubric + submission. |
evaluation_comment |
Free text; required for outcomes failed and needs_changes. |
evaluated_by |
JWT sub of evaluator (v1: admin). |
evaluated_at |
ISO timestamp. |
evaluation_waiver |
Set on approve when bypassing a non-pass state: { "by", "at", "reason" }. |
Gate: require evaluation before approve
Policy resolution (lib/hub-proposal-policy.mjs):
HUB_PROPOSAL_EVALUATION_REQUIRED=1ortrue→ gate on for new proposals.=0orfalse→ gate off.- If unset: read
data/hub_proposal_policy.json; ifproposal_evaluation_required === true, gate on; else off.
When the gate is on, new proposals are created with evaluation_status: "pending" (unless triggers already implied pending). Approve is rejected with 403 / EVALUATION_REQUIRED unless:
evaluation_status === "passed", or- the approve request includes a non-empty
waiver_reason(trimmed length ≥ 3), which recordsevaluation_waiverand an audit entry (approve_waiver).
Exception — personal self-apply (E1): matching Scooling review-tray proposals are created with evaluation_status: "passed" after triggers (see Personal self-apply above), so approve does not need a separate evaluation POST or learner waiver.
When the gate is off at create, new proposals use evaluation_status: "none" unless review triggers force pending; admins may approve without submitting evaluation, but may still submit evaluation for audit.
Hosted canister: The gateway injects evaluation_status: "pending" on create when policy is on (same resolution using repo data/ beside the gateway), then applies E1 for the personal self-apply class (passed + evaluated_by / evaluated_at). Approve rules on the canister allow passed / empty / none. Canister stores review_queue, review_severity, auto_flag_reasons_json, and optional review_hints (V3 ProposalRecord); upgrade migrates existing rows.
Rubric
Default checklist items ship in-repo (hub/proposal-rubric-default.json). Override with data/hub_proposal_rubric.json (same { "items": [{ "id", "label" }] } shape). See PROPOSAL-EVALUATION-RUBRIC-DEFAULT.md.
Who evaluates
Admins and evaluator users may POST …/evaluation. Approve defaults to admin; set HUB_EVALUATOR_MAY_APPROVE=1 to allow evaluator to approve. Discard remains admin on the Node Hub.
Optional LLM review hints
When KNOWTATION_HUB_PROPOSAL_REVIEW_HINTS=1, the Hub may asynchronously populate review_hints (self-hosted: after create; hosted: gateway schedules a canister POST …/review-hints after create). Prompt-injection: treat model output as untrusted; it does not change evaluation_status unless you add a separate policy (not shipped). Privacy: proposal body may be sent to OpenAI or Ollama per lib/llm-complete.mjs.
Related
- HUB-API.md §3.4 Proposals
- IMPORT-EVALS.md (retrieval vs proposal evaluation)
- AGENT-INTEGRATION.md §4 — proposals, metadata, optional Muse linkage
- HUB-API.md proposals section (lifecycle extensions)