Action Hooks and Route Middleware
Pharos supports a narrow, typed extension layer around routes, actions, and queued workflow execution.
Use these seams to add request checks, bounded request shaping, and action-report metadata without turning the app into a custom middleware stack.
Pharos does not expose arbitrary app-owned imperative middleware injection.
Hook families
The supported hook families are:
route.request_hooksroute.middlewareaction.hook_contract.derived_inputsaction.hook_contract.default_inputsaction.hook_contract.normalize_inputsaction.guard_contract.guardsaction.hook_contract.report_annotationsaction.hook_contract.failure_annotations- workflow queue metadata through
queue_name,delay_seconds,
max_attempts, backoff_seconds, and lease_seconds
Use these when the behavior is:
- local to one route, action, or workflow
- declarative and bounded
- explainable as contract rather than hidden runtime code
Example files
| Goal | Best first example | Why | |---|---|---| | JSON-only request admission | apps/dynamic_app/data/contracts/routes.yml | smallest explicit request_hooks example | | form-only request admission | apps/dynamic_app/data/contracts/routes.yml | smallest explicit form-body denial and allow example | | typed request and response middleware | apps/dynamic_app/data/contracts/routes.yml | shows both deny and allow paths | | derived request inputs | apps/dynamic_app/data/contracts/actions.yml | shows header and query values mapped into action input | | defaulted action input values | apps/dynamic_app/data/contracts/actions.yml | shows missing optional-submit values filled by declared defaults | | input normalization before mutation | apps/dynamic_app/data/contracts/actions.yml | shows trim and whitespace collapse without app-owned request glue | | aggregate pre-write guards | apps/commerce_spa/data/features/cart.yml | real cart cap example with negative path | | success and failure annotations | apps/dynamic_app/data/contracts/actions.yml | proves both success and rejection report metadata | | queue-aware workflow metadata | apps/ucal/data/contracts/workflows.yml | larger authenticated workflow proof |
route.request_hooks
Use route.request_hooks when a route should reject the request before normal action execution starts.
Supported request hooks:
json_body_requiredform_body_required
Use it for:
- generated or app-authored JSON mutation routes
- routes that should not silently accept form or empty-body mutation requests
- app-authored form mutation routes that should not silently accept JSON or
other body shapes
Working example:
apps/dynamic_app/data/contracts/routes.ymlapps/ucal/data/contracts/routes.yml
Validation path:
- Build the proving app.
- Run the local runtime gate.
- Send a non-JSON mutation request and confirm it is rejected.
- Send a valid JSON mutation request and confirm it succeeds.
Commands:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- wrong content type is rejected before mutation
- valid JSON request succeeds through the shared runtime
- form-only routes reject JSON requests with the documented failure
- valid form request succeeds through the shared runtime
action.hook_contract.default_inputs
Use default_inputs when the action should supply a bounded fallback value only when the submitted field is missing or empty.
Supported sources:
- literals
- headers
- query parameters
- form fields
- request method
- request path
Behavior rules:
- defaults are applied after
derived_inputs - defaults are applied before required-field validation
- defaults do not overwrite a submitted non-empty value
Working example:
apps/dynamic_app/data/contracts/actions.yml
Validation path:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- a missing
snapshot_labelis filled from the declared literal default - a submitted value is preserved and not replaced by the default
Use this seam when the fallback value is stable and declarative. Do not use it to hide request-derived business logic.
route.middleware
Use route.middleware when a route needs a bounded request or response rule that is still owned by the framework.
Supported request checks:
- required header
- required query parameter
- required form field
Supported response shaping:
- set static response header
- mirror request header into a response header
- mirror query parameter into a response header
Working example:
apps/dynamic_app/data/contracts/routes.yml
Validation path:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- missing
X-Pharos-Proofon the proof route returns the documented denial proof=openmissing from the query route returns the documented denial- allowed requests return success plus the declared response headers
Use this seam when the rule is about admission or header shaping. Do not use it as a substitute for app-owned controller code.
action.hook_contract.derived_inputs
Use derived_inputs when the action needs a bounded value from the request context rather than direct form or JSON submission.
Supported sources:
- literals
- headers
- query parameters
- form fields
- request method
- request path
Target shape:
fieldnames the action input field being populated- source keys name where the value comes from
Working example:
apps/dynamic_app/data/contracts/actions.yml
Validation path:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- the derived value reaches the action without app-owned request plumbing
- missing or malformed required values fail through shared validation
Use this when the value is request-context metadata. Do not use it to smuggle in arbitrary runtime state.
action.hook_contract.normalize_inputs
Use normalize_inputs when an action should clean or canonicalize submitted string input before validation, mutation, and report annotation happen.
Supported normalization kinds:
trimcollapse_whitespacelowercaseuppercaseempty_to_null
Working example:
apps/dynamic_app/data/contracts/actions.yml
Validation path:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- repeated whitespace in submitted task titles is collapsed before the record is
- action reports observe the normalized value instead of the raw submitted value
written
Use this seam when the normalization rule is a bounded string cleanup step. Do not use it for arbitrary parsing, hidden lookup behavior, or app-owned request controller logic.
action.guard_contract.guards
Use guards when an action should fail before commit based on a bounded aggregate or existence check.
Supported guard kinds:
countsumexists
Supported bounds:
minimummaximumequals
sum guards may also use add so the pending write can be evaluated before the mutation is committed.
Working examples:
apps/commerce_spa/data/features/cart.ymlapps/dynamic_app/data/contracts/actions.yml
Validation path:
bin/pharos build app commerce_spa --build-dir dist
sh scripts/check.sh commerce-spa postgresql-runtime
Expected result:
- invalid mutation is blocked before commit
- the rejection carries the declaration-local message
- normal checkout behavior still stays green
action.hook_contract.report_annotations
Use success annotations when an action report should carry bounded metadata that helps operators understand what just happened.
Typical uses:
- store normalized input
- record transport classification
- include request-derived fields that matter after success
Working example:
apps/dynamic_app/data/contracts/actions.yml
Validation path:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- action reports include fields such as normalized title and request transport
action.hook_contract.failure_annotations
Use failure annotations when a rejected or failed action should still leave a structured operator-facing report.
Typical uses:
- attempted transition kind
- approver or actor identity
- resource identifier
Working example:
apps/dynamic_app/data/contracts/actions.yml
Validation path:
bin/pharos build app dynamic_app --build-dir dist
sh scripts/check.sh dynamic-app native-runtime
Expected result:
- a rejected approval flow persists the declared failure metadata
- failed mutation state is not committed just to preserve operator context
Workflow queue metadata
Use workflow queue metadata when queued or delayed work needs declared routing without exposing queue internals.
Supported fields:
queue_namedelay_secondsmax_attemptsbackoff_secondslease_seconds
Working example:
apps/ucal/data/contracts/workflows.yml
Validation path:
bin/pharos build app ucal --build-dir dist
sh scripts/check.sh ucal workflow-jobs
Expected result:
- queued work records lifecycle events
- started, retried, failed, and succeeded paths remain observable through the
- emitted lifecycle payloads include the declared queue lane and retry-policy
shared framework contract
metadata
When not to use these seams
Do not use these seams when you need:
- arbitrary request-pipeline code
- direct queue implementation control
- raw socket or response-stream takeover
- direct access to internal runtime structures
At that point the request is no longer “a narrow extension seam.” It is a framework feature request.