The raise and clear of first-view-unanswered dropped a failed publish (_ = say). The binding comment named the bus module's own stream and 30 days; the part hears the seat's events on the shared stream, 7 days. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
491 lines
20 KiB
Go
491 lines
20 KiB
Go
package grants
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
|
|
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"git.novox.be/novox/mesh-sdk/go/views"
|
|
)
|
|
|
|
// **The grants part's first views** (novox/hq ADR 0615 §1, §4 to §6, ADR 0621, ADR 0622; design 25 § 12.2; DLV-F4r,
|
|
// DLV-R1). The part reads the assigner's placement and wants and the indexer's manifests, modules and verdicts no longer
|
|
// from their buckets, but by each owner's first-view tool, asked once at its start, then from the owner's views family
|
|
// alone (each event one changed key with its revision), asking once more only on a gap in revisions, never on a timer.
|
|
// The verdicts alone are followed on the indexer's manifest-accepted and manifest-refused, which carry the revision
|
|
// (ADR 0622 §2), not on its views family (HearVerdict).
|
|
// Started while an owner is down it keeps asking and never reads the owner's state; past the bound (two minutes) it
|
|
// raises FirstViewUnanswered naming the owner and the tool, cleared by the answer.
|
|
//
|
|
// **An answer empty at revision 0 drops no login**: until an owner answers above revision 0 the state reads as a failed
|
|
// read, so the part keeps the user list it has in force, and FirstViewEmpty stands, naming the owner and the operator's
|
|
// step (the owner writes its state again from its sources, then grants_first_view_again); an answer above 0 clears it.
|
|
|
|
// FirstView is one owner's state the part reads by its first view: the owner, the tool its `invokes` names, the state,
|
|
// and the read it stands for in the part's inputs (`<owner>.<state>`).
|
|
type FirstView struct {
|
|
Owner string `json:"owner"`
|
|
Tool string `json:"tool"`
|
|
State string `json:"state"`
|
|
Read string `json:"read"`
|
|
}
|
|
|
|
// The owners.
|
|
const (
|
|
AssignerModule = "mesh-delivery-assigner"
|
|
IndexerModule = "mesh-delivery-indexer"
|
|
)
|
|
|
|
// FirstViews are the five (ADR 0615 §3 as ADR 0622 §1 amends it: the grants part's placement, wants, manifests,
|
|
// modules and verdicts).
|
|
var FirstViews = []FirstView{
|
|
{Owner: AssignerModule, Tool: AssignerModule + ".placement", State: "placement", Read: ReadPlacement},
|
|
{Owner: AssignerModule, Tool: AssignerModule + ".wants", State: "wants", Read: ReadWants},
|
|
{Owner: IndexerModule, Tool: IndexerModule + ".manifests", State: "manifests", Read: ReadManifests},
|
|
{Owner: IndexerModule, Tool: IndexerModule + ".modules", State: "modules", Read: ReadModules},
|
|
{Owner: IndexerModule, Tool: IndexerModule + ".verdicts", State: "verdicts", Read: ReadVerdicts},
|
|
}
|
|
|
|
// The conditions of the first views, each naming the owner and its state.
|
|
const (
|
|
FirstViewUnanswered = "mesh-grants.first-view-unanswered"
|
|
FirstViewEmpty = "mesh-grants.first-view-empty"
|
|
)
|
|
|
|
// KeyFirstViews is the key of the part's own state user-list where its process keeps what each first view holds, which
|
|
// grants_first_views reads.
|
|
const KeyFirstViews = "first-views"
|
|
|
|
// ErrFirstViewNotYet is a read of a first view not answered above revision 0: the part composes as from a failed read.
|
|
var ErrFirstViewNotYet = errors.New("not answered above revision 0 yet")
|
|
|
|
// Views is the part's reads of the five owners' states through their first views: a Reader and a Watcher for those
|
|
// states, every other read going to Rest.
|
|
type Views struct {
|
|
// Followers are the five, by the read they stand for.
|
|
Followers map[string]*views.Follower
|
|
// Rest reads every other state, and Watching watches it (nil: watched by nobody, as in a tool).
|
|
Rest Reader
|
|
Watching Watcher
|
|
|
|
said *untilSaid
|
|
}
|
|
|
|
// NewViews makes the five followers: ask calls an owner's tool (through the part's own account), say says a condition,
|
|
// changed wakes a pass. Started by Start.
|
|
func NewViews(ask func(tool string) func(context.Context, string) (views.Answer, error), say func(oc.Condition) error,
|
|
changed func(), logf func(string, ...any)) *Views {
|
|
v := &Views{Followers: map[string]*views.Follower{}}
|
|
sayer := &untilSaid{say: say, logf: logf, unsaid: map[string]oc.Condition{}}
|
|
v.said = sayer
|
|
for _, fv := range FirstViews {
|
|
fv := fv
|
|
f := &views.Follower{Owner: fv.Owner, Tool: fv.Tool, State: fv.State, Ask: ask(fv.Tool), Logf: logf,
|
|
Changed: func(string, json.RawMessage, bool, time.Time) { changed() }}
|
|
f.Raise = func(s views.Status) {
|
|
sayer.Say(unansweredCondition(fv, s, true))
|
|
changed()
|
|
}
|
|
f.Clear = func(s views.Status) {
|
|
sayer.Say(unansweredCondition(fv, s, false))
|
|
changed()
|
|
}
|
|
v.Followers[fv.Read] = f
|
|
}
|
|
return v
|
|
}
|
|
|
|
// untilSaid says a first view's condition; one the condition keeper did not take is logged and kept, and said again at
|
|
// the part's next pass (SayUnsaid), until it lands or a newer word on the same key replaces it: a raise or a clear is
|
|
// never dropped on a failed publish, and nothing waits on a timer for it.
|
|
type untilSaid struct {
|
|
say func(oc.Condition) error
|
|
logf func(string, ...any)
|
|
mu sync.Mutex
|
|
unsaid map[string]oc.Condition
|
|
}
|
|
|
|
// Say says c; when it fails it is kept to be said again.
|
|
func (u *untilSaid) Say(c oc.Condition) {
|
|
if u.say == nil {
|
|
return
|
|
}
|
|
err := u.say(c)
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if err == nil {
|
|
delete(u.unsaid, c.Key)
|
|
return
|
|
}
|
|
u.unsaid[c.Key] = c
|
|
if u.logf != nil {
|
|
u.logf("the condition %s (%s) could not be said: %v; said again at the next pass", c.Key, c.State, err)
|
|
}
|
|
}
|
|
|
|
// SayUnsaid says again every condition not taken yet: the part's pass calls it.
|
|
func (v *Views) SayUnsaid() {
|
|
if v == nil || v.said == nil {
|
|
return
|
|
}
|
|
u := v.said
|
|
u.mu.Lock()
|
|
pending := make([]oc.Condition, 0, len(u.unsaid))
|
|
for _, k := range slices.Sorted(maps.Keys(u.unsaid)) {
|
|
pending = append(pending, u.unsaid[k])
|
|
}
|
|
u.mu.Unlock()
|
|
for _, c := range pending {
|
|
u.mu.Lock()
|
|
current, still := u.unsaid[c.Key]
|
|
u.mu.Unlock()
|
|
if still && current.State == c.State {
|
|
u.Say(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start asks each first view on a goroutine of its own, until it is answered or ctx ends; each answer wakes a pass.
|
|
func (v *Views) Start(ctx context.Context, changed func()) {
|
|
for _, f := range v.Followers {
|
|
f := f
|
|
go func() {
|
|
if f.Start(ctx) == nil {
|
|
changed()
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Following says the follower of the owner's views event state, or nil.
|
|
func (v *Views) Following(owner, state string) *views.Follower {
|
|
for _, fv := range FirstViews {
|
|
if fv.Owner == owner && fv.State == state {
|
|
return v.Followers[fv.Read]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ready is the follower of read, answered above revision 0; nil and why when it is not one of the five, or not yet.
|
|
func (v *Views) ready(read string) (*views.Follower, bool, error) {
|
|
f, ok := v.Followers[read]
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
select {
|
|
case <-f.Ready():
|
|
default:
|
|
return f, true, fmt.Errorf("%s by %s: %w", read, f.Tool, ErrFirstViewNotYet)
|
|
}
|
|
if s := f.Status(); s.Revision == 0 {
|
|
return f, true, fmt.Errorf("%s by %s answered empty at revision 0: %w", read, f.Tool, ErrFirstViewNotYet)
|
|
}
|
|
return f, true, nil
|
|
}
|
|
|
|
// Get reads one key: from its first view for the five, else from Rest.
|
|
func (v *Views) Get(state, key string) ([]byte, bool, error) {
|
|
f, mine, err := v.ready(state)
|
|
if !mine {
|
|
return v.Rest.Get(state, key)
|
|
}
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
raw, ok := f.Get(key)
|
|
return raw, ok, nil
|
|
}
|
|
|
|
// Keys lists a state's keys: from its first view for the five, else from Rest.
|
|
func (v *Views) Keys(state string) ([]string, error) {
|
|
f, mine, err := v.ready(state)
|
|
if !mine {
|
|
return v.Rest.Keys(state)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := f.Values()
|
|
out := make([]string, 0, len(values))
|
|
for k := range values {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
// Watch watches Rest's states; the five are followed by their first views and events, never watched.
|
|
func (v *Views) Watch(state string, changed func(key string, value []byte), ended func(error)) error {
|
|
if _, mine := v.Followers[state]; mine || v.Watching == nil {
|
|
return nil
|
|
}
|
|
return v.Watching.Watch(state, changed, ended)
|
|
}
|
|
|
|
// Statuses are what each first view holds now, in FirstViews' order.
|
|
func (v *Views) Statuses() []views.Status {
|
|
out := make([]views.Status, 0, len(FirstViews))
|
|
for _, fv := range FirstViews {
|
|
out = append(out, v.Followers[fv.Read].Status())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Again asks the first view of one state (its state name, `placement`, or its tool) once more, or every one when state is
|
|
// empty, each on a goroutine of its own; it answers which it asked, or refuses a state that is none of the five.
|
|
func (v *Views) Again(ctx context.Context, state, why string) ([]string, error) {
|
|
var asked []string
|
|
for _, fv := range FirstViews {
|
|
if state != "" && state != fv.State && state != fv.Tool && state != fv.Read {
|
|
continue
|
|
}
|
|
f := v.Followers[fv.Read]
|
|
go func() { _ = f.Again(ctx, why) }()
|
|
asked = append(asked, fv.Tool)
|
|
}
|
|
if len(asked) == 0 {
|
|
return nil, fmt.Errorf("%q is no first view of the grants part: one of placement, wants, manifests, modules, verdicts, or none for all", state)
|
|
}
|
|
return asked, nil
|
|
}
|
|
|
|
// VerdictEvents are the indexer's two events the part follows the verdicts on after their first view (ADR 0622 §2), as its
|
|
// manifest's consumes name them.
|
|
var VerdictEvents = []string{IndexerModule + "." + delivery.ManifestAcceptedEvent, IndexerModule + "." + delivery.ManifestRefusedEvent}
|
|
|
|
// HearVerdict takes one of the indexer's manifest-accepted or manifest-refused (event, its name) into the verdicts' first
|
|
// view, as one change of the verdicts state at the revision it carries (ADR 0622 §2): the manifest's digest its key, the
|
|
// verdict it says its value. A revision past the one after the picture's is a gap, and so is an event carrying no
|
|
// revision (an indexer that does not say it) or one saying no manifest above the picture's revision (the empty accepted
|
|
// verdict of a module leaving the trunk): the first view is asked once more on its own goroutine, said in the log. A
|
|
// revision already in the picture is passed over. Data that does not read is answered as an error, and nothing is asked.
|
|
func (v *Views) HearVerdict(ctx context.Context, event string, data []byte, logf func(string, ...any)) error {
|
|
f := v.Followers[ReadVerdicts]
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
var verdict delivery.ManifestVerdict
|
|
var digest string
|
|
var revision uint64
|
|
switch event {
|
|
case delivery.ManifestAcceptedEvent:
|
|
var a delivery.ManifestAccepted
|
|
if err := json.Unmarshal(data, &a); err != nil {
|
|
return fmt.Errorf("%s does not read: %w", event, err)
|
|
}
|
|
verdict = delivery.ManifestVerdict{V: delivery.Version, Module: a.Module, State: delivery.VerdictAccepted, Judge: a.Judge}
|
|
digest, revision = a.Digest, a.Revision
|
|
case delivery.ManifestRefusedEvent:
|
|
var r delivery.ManifestRefused
|
|
if err := json.Unmarshal(data, &r); err != nil {
|
|
return fmt.Errorf("%s does not read: %w", event, err)
|
|
}
|
|
verdict = delivery.ManifestVerdict{V: delivery.Version, Module: r.Module, State: delivery.VerdictRefused, Judge: r.Judge, Problems: r.Problems}
|
|
digest, revision = r.Digest, r.Revision
|
|
default:
|
|
return fmt.Errorf("%q is not a verdict event of the indexer", event)
|
|
}
|
|
held := f.Status()
|
|
gap := func(why string) error {
|
|
logf("views: a gap in %s (%s): its first view %s is asked once more", f.State, why, f.Tool)
|
|
go func() { _ = f.Gap(ctx, why) }()
|
|
return nil
|
|
}
|
|
if revision == 0 {
|
|
return gap(fmt.Sprintf("the indexer's %s of %s carries no revision", event, verdict.Module))
|
|
}
|
|
key, err := delivery.VerdictsKey(digest)
|
|
if err != nil {
|
|
if revision <= held.Revision {
|
|
return nil
|
|
}
|
|
return gap(fmt.Sprintf("the indexer's %s of %s at revision %d names no manifest", event, verdict.Module, revision))
|
|
}
|
|
// A revision below the picture's is a gap (ADR 0615 §5): the verdict events carry no mark of when the indexer's verdicts
|
|
// were made, so one made again shows only as revisions starting low. A redelivery of a verdict the picture already
|
|
// holds for its manifest is passed over, so a consumer's backlog after a restart asks nothing.
|
|
if revision < held.Revision {
|
|
if raw, found := f.Get(key); found {
|
|
if was, err := delivery.DecodeManifestVerdict(raw); err == nil && sameVerdict(was, verdict) {
|
|
return nil
|
|
}
|
|
}
|
|
return gap(fmt.Sprintf("the indexer's %s of %s at revision %d, below the picture's %d: its verdicts were made again, or this verdict was missed",
|
|
event, verdict.Module, revision, held.Revision))
|
|
}
|
|
value, err := json.Marshal(verdict)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if g, why := f.Apply(views.Event{V: 1, Owner: f.Owner, State: f.State, Key: key, Revision: revision, Created: held.Created,
|
|
Value: value, At: time.Now().UTC()}); g {
|
|
return gap(why)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sameVerdict says two verdicts of one manifest say the same: its module, accepted or refused, the judge and the problems.
|
|
func sameVerdict(a, b delivery.ManifestVerdict) bool {
|
|
return a.Module == b.Module && a.State == b.State && a.Judge == b.Judge && slices.Equal(a.Problems, b.Problems)
|
|
}
|
|
|
|
// unansweredCondition is FirstViewUnanswered for one first view, raised or cleared.
|
|
func unansweredCondition(fv FirstView, s views.Status, raised bool) oc.Condition {
|
|
c := oc.Condition{Key: FirstViewUnanswered + "." + fv.Owner + "." + fv.State, Subject: oc.ConditionSubject{Scope: "module", ID: Module}}
|
|
if !raised {
|
|
c.State = occ.StateCleared
|
|
c.Resolved = fv.Tool + " answered at revision " + fmt.Sprint(s.Revision)
|
|
return c
|
|
}
|
|
c.State, c.Severity = occ.StateRaised, occ.SeverityWarning
|
|
c.Summary = fmt.Sprintf("the grants part has no first view of %s's %s: %s has not answered since %s (%s)", fv.Owner, fv.State,
|
|
fv.Tool, s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error))
|
|
c.Headline = "The grants part waits for " + fv.Owner
|
|
c.Explanation = "The grants part reads " + fv.Owner + "'s " + fv.State + " only through " + fv.Tool + ", and keeps asking it; " +
|
|
"it never reads the state itself, and keeps what it composed meanwhile (novox/hq ADR 0615 §6). The answer clears this."
|
|
c.Needs = "a look at " + fv.Owner + "'s runtime of its own: is it running and connected?"
|
|
return c
|
|
}
|
|
|
|
// emptyCondition is FirstViewEmpty for one first view: raised while it answered empty at revision 0, with the
|
|
// operator's step, cleared by an answer above 0.
|
|
func emptyCondition(fv FirstView, raised bool) oc.Condition {
|
|
c := oc.Condition{Key: FirstViewEmpty + "." + fv.Owner + "." + fv.State, Subject: oc.ConditionSubject{Scope: "module", ID: Module}}
|
|
if !raised {
|
|
c.State = occ.StateCleared
|
|
c.Resolved = fv.Tool + " answered above revision 0"
|
|
return c
|
|
}
|
|
step := "the indexer takes each module in again (indexer_take_in)"
|
|
if fv.Owner == AssignerModule {
|
|
step = "the assigner composes the wants again at its next composition"
|
|
}
|
|
c.State, c.Severity = occ.StateRaised, occ.SeverityWarning
|
|
c.Summary = fmt.Sprintf("%s answered %s's %s empty at revision 0, so the grants part keeps the user list in force", fv.Tool, fv.Owner, fv.State)
|
|
c.Headline = fv.Owner + "'s " + fv.State + " is empty"
|
|
c.Explanation = occ.NeedsYou + "a step. The owner holds nothing of " + fv.State + " (its state rebuilt by a recreate that could " +
|
|
"not keep the data, or a fresh mesh), so the grants part drops no login and keeps what it holds; a login revoked meanwhile " +
|
|
"stays until the owner's first change (novox/hq design 25 § 12.2)."
|
|
c.Needs = "let the owner write its state again from its sources (" + step + "), then grants_first_view_again"
|
|
return c
|
|
}
|
|
|
|
// firstViewsSaid is what the part last said and kept of its first views (sayFirstViews).
|
|
type firstViewsSaid struct {
|
|
mu sync.Mutex
|
|
empty map[string]bool
|
|
known bool
|
|
}
|
|
|
|
// FirstViewsState is the value of KeyFirstViews: each first view's status, as the part's process last kept it.
|
|
type FirstViewsState struct {
|
|
V int `json:"v"`
|
|
Views []views.Status `json:"views"`
|
|
}
|
|
|
|
// sayFirstViews keeps the first views' statuses in the part's own state (for grants_first_views) when they change, and
|
|
// raises or clears FirstViewEmpty for each answered at revision 0. Each pass calls it; nil Views is none.
|
|
func (p *Part) sayFirstViews(res *Result) {
|
|
if p.Views == nil {
|
|
return
|
|
}
|
|
statuses := p.Views.Statuses()
|
|
for i := range statuses {
|
|
statuses[i].Asked, statuses[i].Answered = statuses[i].Asked.Truncate(time.Second), statuses[i].Answered.Truncate(time.Second)
|
|
}
|
|
data, _ := json.Marshal(FirstViewsState{V: 1, Views: statuses})
|
|
p.changed(res, StateUserList, KeyFirstViews, data)
|
|
p.fvSaid.mu.Lock()
|
|
defer p.fvSaid.mu.Unlock()
|
|
if p.fvSaid.empty == nil {
|
|
p.fvSaid.empty = map[string]bool{}
|
|
}
|
|
for i, fv := range FirstViews {
|
|
s := statuses[i]
|
|
empty := !s.Answered.IsZero() && s.Revision == 0
|
|
if p.fvSaid.known && p.fvSaid.empty[fv.Read] == empty {
|
|
continue
|
|
}
|
|
if s.Answered.IsZero() && !p.fvSaid.known {
|
|
continue // not answered yet: nothing to say of its emptiness
|
|
}
|
|
if p.Say == nil || p.Say(emptyCondition(fv, empty)) == nil {
|
|
p.fvSaid.empty[fv.Read] = empty
|
|
}
|
|
}
|
|
p.fvSaid.known = true
|
|
}
|
|
|
|
// FirstViewsOf answers the tool grants_first_views: what the part's process last kept of each first view (owner, tool,
|
|
// state, revision, when asked and answered, asks, hand acts, whether its condition is raised).
|
|
func (p *Part) FirstViewsOf() (any, error) {
|
|
raw, rev, found, err := p.Store.Get(StateUserList, KeyFirstViews)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateUserList, KeyFirstViews, err)
|
|
}
|
|
if !found {
|
|
return map[string]any{"views": []any{}, "said": "the part's process has kept no first view yet: it has not passed since it started"}, nil
|
|
}
|
|
var st FirstViewsState
|
|
if err := json.Unmarshal(raw, &st); err != nil {
|
|
return nil, fmt.Errorf("its own %s %s does not read: %w", StateUserList, KeyFirstViews, err)
|
|
}
|
|
return map[string]any{"views": st.Views, "revision": rev, "first-views": FirstViews}, nil
|
|
}
|
|
|
|
// AskFirstViewAgain answers the tool grants_first_view_again: it asks the part's process to ask one first view (by its
|
|
// state, or every one when state is empty) once more now, and records the hand act through record (the seat
|
|
// mesh-hand-acts); a state that is none of the five is refused before anything is asked.
|
|
func (p *Part) AskFirstViewAgain(caller, state, why string, record func(what, why string) error) (any, error) {
|
|
which := AllFirstViews
|
|
if state != "" {
|
|
which = ""
|
|
for _, fv := range FirstViews {
|
|
if state == fv.State || state == fv.Tool || state == fv.Read {
|
|
which = fv.State
|
|
}
|
|
}
|
|
if which == "" {
|
|
return nil, fmt.Errorf("%q is no first view of the grants part: one of placement, wants, manifests, modules, verdicts, or none for all", state)
|
|
}
|
|
}
|
|
if strings.TrimSpace(why) == "" {
|
|
return nil, errors.New("grants_first_view_again needs why: it is recorded as a hand act")
|
|
}
|
|
if record != nil {
|
|
if err := record("grants_first_view_again "+which, why); err != nil {
|
|
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was asked: %w", err)
|
|
}
|
|
}
|
|
ask, _ := json.Marshal(Ask{By: caller, At: p.now(), FirstView: which})
|
|
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the ask could not be written: %w", err)
|
|
}
|
|
return map[string]any{"asked": rev, "first-view": which,
|
|
"says": "the part's process asks it once more at this ask; grants_first_views shows its answer"}, nil
|
|
}
|
|
|
|
// Unwrapped is a call whose answer is read as the tool's own text where the runtime wraps it as content (unwrap), as
|
|
// a first view's answer is decoded (views.AskVia).
|
|
func Unwrapped(call func(key string, body any) (json.RawMessage, error)) func(key string, body any) (json.RawMessage, error) {
|
|
return func(key string, body any) (json.RawMessage, error) {
|
|
raw, err := call(key, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unwrap(raw), nil
|
|
}
|
|
}
|